From c5885f6ef9accacdda9f37a8ca5e273ec8013ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:54:13 +0000 Subject: [PATCH 01/46] fix: repair test suite truncation and latent failures it was hiding The CLI spec invoked 'agent create' without the required --purpose option, causing Thor to exit(1) mid-suite and silently truncate the run at 65 of 474 examples. Repairing that revealed 12 latent failures: - CLI 'agent create' used Agent.new with a block that initialize never yields, silently discarding role/purpose; now uses Agent.build - PersistentAgentStore#store never assigned generated IDs back to the agent, so re-storing an agent duplicated it instead of versioning it - PersistentAgentStore#list_all now accepts both the documented all(filter: {...}) form (ADR-015) and the bare all(capability: ...) - Agentic.register_capability/.assemble_agent now use the public readers instead of module ivars - AgentAssemblyEngine keyword inference now stem-matches capability names ('Analyze the data' matches data_analysis), compounds importance across sources, and lets explicit input capabilities raise importance over weaker keyword matches - ExecutionObserver#display_progress prints the newline that ends the carriage-return progress line when all tasks complete - Align LLM strategy specs with the implemented fallback design and replace unsupported expect_any_instance_of + have_received Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- .gitignore | 1 + lib/agentic.rb | 4 +- lib/agentic/agent_assembly_engine.rb | 57 ++++++++++++------- lib/agentic/cli.rb | 2 +- lib/agentic/cli/execution_observer.rb | 3 + lib/agentic/persistent_agent_store.rb | 8 ++- spec/agentic/cli_spec.rb | 8 ++- .../llm_assisted_composition_strategy_spec.rb | 35 ++++++++---- .../agent_assembly_integration_spec.rb | 4 +- 9 files changed, 85 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index f3e5fc5..36105cc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ .rspec_status # environment variables .env +result-*.json diff --git a/lib/agentic.rb b/lib/agentic.rb index 992da6b..3712104 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -82,7 +82,7 @@ def self.initialize_agent_assembly # @return [CapabilitySpecification] The registered capability def self.register_capability(capability, provider) initialize_agent_assembly - @agent_capability_registry.register(capability, provider) + agent_capability_registry.register(capability, provider) end # Assemble an agent for a task @@ -99,7 +99,7 @@ def self.assemble_agent(task, strategy: nil, store: true, use_llm: false) strategy = LlmAssistedCompositionStrategy.new end - @agent_assembly_engine.assemble_agent(task, strategy: strategy, store: store) + agent_assembly_engine.assemble_agent(task, strategy: strategy, store: store) end # Create an LLM-assisted composition strategy diff --git a/lib/agentic/agent_assembly_engine.rb b/lib/agentic/agent_assembly_engine.rb index 787f99a..4a5dd02 100644 --- a/lib/agentic/agent_assembly_engine.rb +++ b/lib/agentic/agent_assembly_engine.rb @@ -246,24 +246,29 @@ def infer_capabilities_from_description(description, requirements) ] known_capabilities.each do |capability| - if description.downcase.include?(capability.downcase) - requirements[capability] ||= { + next unless description_mentions_capability?(description, capability) + + if requirements[capability] + # Mentions across multiple sources (description, agent spec name, + # instructions, ...) compound the capability's importance + requirements[capability][:importance] = [requirements[capability][:importance] + 0.15, 1.0].min + else + requirements[capability] = { importance: 0.5, # Default importance version_constraint: nil # Any version } - - # Increase importance if mentioned multiple times - count = description.downcase.scan(capability.downcase).count - requirements[capability][:importance] += 0.1 * count if count > 1 end + + # Increase importance if mentioned multiple times in this source + count = description.downcase.scan(capability.downcase).count + requirements[capability][:importance] += 0.1 * count if count > 1 end - # Special case for test tasks + # Special case for code-generation tasks: a strong signal that should + # raise the importance even when keyword matching already found it if description.downcase.include?("code") && description.downcase.include?("generate") - requirements["code_generation"] ||= { - importance: 0.8, # High importance for code generation tasks - version_constraint: nil # Any version - } + requirement = (requirements["code_generation"] ||= {importance: 0.0, version_constraint: nil}) + requirement[:importance] = [requirement[:importance], 0.8].max end # Add common capabilities for all tasks @@ -273,6 +278,22 @@ def infer_capabilities_from_description(description, requirements) } end + # 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" + # @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("_", " ")) + + capability.split("_").all? do |word| + stem = word[0, 5] + stem.length >= 3 && text.include?(stem) + end + end + # Infer capabilities from an agent specification # @param agent_spec [AgentSpecification] The agent specification # @param requirements [Hash] The requirements hash to update @@ -326,20 +347,18 @@ def infer_capabilities_from_input(input, requirements) if capabilities.is_a?(Array) capabilities.each do |capability| if capability.is_a?(String) - requirements[capability] ||= { - importance: 0.9, # Very high importance for explicitly requested capabilities - version_constraint: nil # Any version - } + # Explicitly requested capabilities outrank keyword inference + requirement = (requirements[capability] ||= {importance: 0.0, version_constraint: nil}) + requirement[:importance] = [requirement[:importance], 0.9].max elsif capability.is_a?(Hash) name = capability[:name] || capability["name"] version = capability[:version] || capability["version"] importance = capability[:importance] || capability["importance"] || 0.9 if name - requirements[name] ||= { - importance: importance, - version_constraint: version - } + requirement = (requirements[name] ||= {importance: 0.0, version_constraint: version}) + requirement[:importance] = [requirement[:importance], importance].max + requirement[:version_constraint] ||= version end end end diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index 0b22967..df44b90 100644 --- a/lib/agentic/cli.rb +++ b/lib/agentic/cli.rb @@ -208,7 +208,7 @@ def create(name) # Create spinner for agent creation agent = UI.with_spinner("Creating agent: #{name}") do # Create new agent - agent = Agentic::Agent.new do |a| + agent = Agentic::Agent.build do |a| a.role = options[:role] a.purpose = options[:purpose] a.backstory = options[:backstory] || "" diff --git a/lib/agentic/cli/execution_observer.rb b/lib/agentic/cli/execution_observer.rb index 70db8a7..2b619af 100644 --- a/lib/agentic/cli/execution_observer.rb +++ b/lib/agentic/cli/execution_observer.rb @@ -594,6 +594,9 @@ def display_progress :blue )}" $stdout.flush + elsif total > 0 + # All tasks accounted for - terminate the carriage-return progress line + puts end end end diff --git a/lib/agentic/persistent_agent_store.rb b/lib/agentic/persistent_agent_store.rb index 6a294d2..cf68e56 100644 --- a/lib/agentic/persistent_agent_store.rb +++ b/lib/agentic/persistent_agent_store.rb @@ -36,8 +36,10 @@ def initialize(storage_path = nil, registry = AgentCapabilityRegistry.instance, # @param metadata [Hash] Additional metadata to store with the agent # @return [String] The ID of the stored agent def store(agent, name: nil, metadata: {}) - # Generate ID if agent doesn't have one + # Generate ID if agent doesn't have one, and assign it back so that + # storing the same agent again versions it instead of duplicating it id = agent&.id || SecureRandom.uuid + agent.id = id if agent&.respond_to?(:id=) && agent.id.nil? # Generate version version = generate_version(id) @@ -109,6 +111,10 @@ def build_agent(id_or_name, version: nil) # @option filter [Hash] :metadata Filter by metadata values # @return [Array] Array of agent configurations def list_all(filter = {}) + # Support both `all(capability: "x")` and the documented + # `all(filter: {capability: "x"})` forms (see ADR-015) + filter = filter[:filter] || {} if filter.key?(:filter) + results = [] @index.each do |id, versions| diff --git a/spec/agentic/cli_spec.rb b/spec/agentic/cli_spec.rb index 259f2e1..6790d83 100644 --- a/spec/agentic/cli_spec.rb +++ b/spec/agentic/cli_spec.rb @@ -49,10 +49,16 @@ # Stub UI.box to return a simpler string for testing allow(Agentic::UI).to receive(:box).and_return("Agent Created") + # Avoid touching the real on-disk agent store + agent_store = instance_double(Agentic::PersistentAgentStore, store: "agent-id") + allow(Agentic).to receive(:initialize_agent_assembly) + allow(Agentic).to receive(:agent_store).and_return(agent_store) + # Execute the agent create command - described_class.start(["agent", "create", "TestAgent", "--role=Tester", "--instructions=Test instructions"]) + described_class.start(["agent", "create", "TestAgent", "--role=Tester", "--purpose=Run the test suite"]) # Verify output + expect(agent_store).to have_received(:store) expect(output.string).to include("Agent Created") end end diff --git a/spec/agentic/llm_assisted_composition_strategy_spec.rb b/spec/agentic/llm_assisted_composition_strategy_spec.rb index 3c7adf2..8cf1158 100644 --- a/spec/agentic/llm_assisted_composition_strategy_spec.rb +++ b/spec/agentic/llm_assisted_composition_strategy_spec.rb @@ -99,10 +99,15 @@ allow(llm_response).to receive(:to_s).and_return(json_response) end - it "filters out invalid capabilities" do + it "filters out invalid capabilities and falls back to the default strategy" do + default_strategy = instance_double(Agentic::DefaultCompositionStrategy) + allow(Agentic::DefaultCompositionStrategy).to receive(:new).and_return(default_strategy) + allow(default_strategy).to receive(:select_capabilities).and_return([]) + capabilities = strategy.select_capabilities(requirements, registry) expect(capabilities).to be_empty + expect(default_strategy).to have_received(:select_capabilities).with(requirements, registry) end end @@ -139,16 +144,24 @@ task = instance_double(Agentic::Task) agent = instance_double(Agentic::Agent) - # Let initialization happen normally, but mock the assembly engine's assemble_agent method - allow_any_instance_of(Agentic::AgentAssemblyEngine).to receive(:assemble_agent).and_return(agent) - - result = Agentic.assemble_agent(task, use_llm: true) - - expect(result).to eq(agent) - expect_any_instance_of(Agentic::AgentAssemblyEngine).to have_received(:assemble_agent).with( - task, - hash_including(strategy: instance_of(described_class)) - ) + # Swap in a doubled assembly engine so we can observe the strategy it receives + engine = instance_double(Agentic::AgentAssemblyEngine, assemble_agent: agent) + original_engine = Agentic.instance_variable_get(:@agent_assembly_engine) + allow(Agentic).to receive(:initialize_agent_assembly) + Agentic.instance_variable_set(:@agent_assembly_engine, engine) + + begin + result = Agentic.assemble_agent(task, use_llm: true) + + expect(result).to eq(agent) + expect(engine).to have_received(:assemble_agent).with( + task, + strategy: instance_of(described_class), + store: true + ) + ensure + Agentic.instance_variable_set(:@agent_assembly_engine, original_engine) + end end end end diff --git a/spec/integration/agent_assembly_integration_spec.rb b/spec/integration/agent_assembly_integration_spec.rb index 0a57b38..c0423c3 100644 --- a/spec/integration/agent_assembly_integration_spec.rb +++ b/spec/integration/agent_assembly_integration_spec.rb @@ -155,7 +155,7 @@ it "finds a stored agent for similar tasks" do # 1. Create and store an agent with data_analysis capability - original_agent = Agentic::Agent.new do |a| + original_agent = Agentic::Agent.build do |a| a.role = "Data Analyst" a.purpose = "Analyze financial data" a.backstory = "I am a data analysis expert" @@ -222,7 +222,7 @@ expect(registry.get("comprehensive_analysis")).to eq(composed_capability) # 3. Create an agent with the composed capability - agent = Agentic::Agent.new do |a| + agent = Agentic::Agent.build do |a| a.role = "Report Generator" a.purpose = "Generate comprehensive reports" end From 5540067809a98ef645923cf6b83a2ec51aa9f47c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:59:33 +0000 Subject: [PATCH 02/46] docs: ten Rubyist perspectives review with field-note index Documents the multi-perspective review of the codebase (Matz, DHH, tenderlove, fxn, ioquatix, Jeremy Evans, solnic, Mike Perham, Sandi Metz, ankane), the synthesis of what they agree on, and the prologue field notes on the truncated test suite discovered before the builds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/README.md | 174 +++++++++++++++++++ docs/perspectives/field-notes/00-prologue.md | 38 ++++ 2 files changed, 212 insertions(+) create mode 100644 docs/perspectives/README.md create mode 100644 docs/perspectives/field-notes/00-prologue.md diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md new file mode 100644 index 0000000..2646c40 --- /dev/null +++ b/docs/perspectives/README.md @@ -0,0 +1,174 @@ +# Ten Rubyist Perspectives on Agentic + +An exercise in multi-perspective review: we embraced the personas of ten of the +most prolific Rubyists, asked how each would use this gem, what they would +build with it, what would delight or confuse them — and then **built the thing +each persona proposed**, taking field notes in character along the way. + +> These are imagined characterizations of public figures, grounded in their +> well-known public work and stated philosophies. They are not the actual +> opinions of the people named. + +## The reviews, the builds, the field notes + +| # | Persona | Lens | What they built | Field notes | +|---|---------|------|-----------------|-------------| +| 0 | Prologue | The broken baseline | Repaired the truncated test suite | [00-prologue.md](field-notes/00-prologue.md) | +| 1 | Matz | Language design, developer happiness | `examples/haiku_agent.rb` — the three-line agent | [01-matz.md](field-notes/01-matz.md) | +| 2 | DHH | Conceptual compression | `Agentic.run("goal")` — the one-liner | [02-dhh.md](field-notes/02-dhh.md) | +| 3 | Aaron Patterson | Performance, runtime internals | `benchmark/boot.rb` + thread-safe assembly init | [03-tenderlove.md](field-notes/03-tenderlove.md) | +| 4 | Xavier Noria | Code loading correctness | Zeitwerk as the single loader; 19× faster require | [04-fxn.md](field-notes/04-fxn.md) | +| 5 | Samuel Williams | Structured concurrency | Reactor-composable `PlanOrchestrator` | [05-ioquatix.md](field-notes/05-ioquatix.md) | +| 6 | Jeremy Evans | Fail-fast correctness | Fail-fast credential validation, `ConfigurationError` | [06-jeremyevans.md](field-notes/06-jeremyevans.md) | +| 7 | Piotr Solnica | Types and boundaries | Capability input validation against declared schemas | [07-solnic.md](field-notes/07-solnic.md) | +| 8 | Mike Perham | Durability, boring reliability | `ExecutionJournal` — crash-surviving plan state | [08-mperham.md](field-notes/08-mperham.md) | +| 9 | Sandi Metz | Small objects, honest messages | `execute_with_schema` honesty + subclass-safe factory | [09-sandimetz.md](field-notes/09-sandimetz.md) | +| 10 | Andrew Kane | Practical ML gems | Pluggable `web_search` capability backend | [10-ankane.md](field-notes/10-ankane.md) | + +--- + +## 1. Yukihiro "Matz" Matsumoto — optimizing for happiness + +**What I'd build:** Nothing big — open `bin/console` and play. Can I make an +agent in three lines that makes me smile? + +**What interests me:** The block-based builder (`Agent.build do |a|`) and the +`StructuredOutputs::Schema` DSL are genuinely Rubyish. An AI orchestration gem +that reads like Ruby instead of like a Python port makes me happy. + +**What's confusing:** `Task#perform(agent)` vs `Agent#execute(task)` — the +same act expressed from two directions, and `Agent#execute` even calls +`task.perform(self)` back. Which object owns the verb? + +**Worked well:** The plain-English goal → plan flow. **Didn't:** +`raise "Capability not found: #{name}"` — bare `RuntimeError` strings when +`Agentic::Error` already exists. Errors deserve names too. + +## 2. DHH — conceptual compression, majestic monolith + +**What I'd build:** The 80% version: `Agentic.run("Summarize this week's +support tickets")`. One line, batteries included. + +**What's confusing:** The gap between documentation and code. The architecture +documents promise a `MetaLearningSystem`, `StreamingObservabilityHub`, +`InterventionPortal` — layers documented before they exist. Four architectural +layers for a gem with one real user path. + +**Worked well:** `PlanOrchestrator`'s lifecycle hooks — a real, earned +abstraction. **Didn't:** Everything you must understand before your first +agent runs. Compress it. Delete half the nouns. + +## 3. Aaron Patterson (tenderlove) — performance and runtime internals + +**What I'd build:** First, a benchmark: `require "agentic"` was eagerly +loading Thor and six tty-* gems into every library consumer. Your web app was +booting a progress-bar library. + +**What interests me:** `PlanOrchestrator` on `Async` with a semaphore and +barrier — I want to throw 500 tasks at it and watch allocations. + +**Didn't work:** `initialize_agent_assembly` memoized global state with no +mutex — two threads race, both build a `PersistentAgentStore`. I've fixed this +bug in Rails at least nine times. Hi! + +## 4. Xavier Noria — Zeitwerk author + +**What's confusing:** `lib/agentic.rb` called `loader.setup` and then +immediately issued nine `require_relative` calls for constants Zeitwerk +already manages, plus more scattered inside files. Two loading mechanisms with +different semantics. Either trust the loader or don't use one. + +**Worked well:** File/constant naming is perfectly conventional — the loader +maps cleanly (once `ui` joined `cli` in the inflector). + +## 5. Samuel Williams (ioquatix) — async maintainer + +**What I'd build:** A streaming agent server on Falcon. The gem chose +`async ~> 2.0`, so it's already in my house. + +**What interests me:** `execute_plan` uses `Async::Barrier` with a `Semaphore` +parented to it — the documented-correct composition. Someone read the manual. + +**What's confusing:** The orchestrator created its own root `Async` block. +Called inside an existing reactor (say, under Falcon), you get a nested event +loop rather than joining the parent. + +## 6. Jeremy Evans — minimal dependencies, ruthless correctness + +**What's alarming:** `Configuration#initialize` defaulted `access_token` to +the string `"ollama"`. A silent fake credential means misconfiguration fails +at request time with a confusing 401 instead of loudly at boot. Fail fast. + +**What's confusing:** Twelve runtime dependencies for a library, six of them +tty-* UI gems, plus `ostruct`. Those belong in a separate `agentic-cli` gem. + +## 7. Piotr Solnica — dry-rb, types and boundaries + +**What interests me:** The instinct is *so close* to ours: +`AgentSpecification`, `TaskDefinition`, `ExpectedAnswerFormat` are value +objects with `to_h`/`from_hash` — `Dry::Struct` written by hand. + +**Didn't work:** Types declared but not enforced. `CapabilitySpecification` +defines `inputs:` with types and `required:` flags, and then nothing ever +validates inputs against them. Ceremony without safety — the worst of both +worlds. + +## 8. Mike Perham — Sidekiq, boring reliability + +**What's confusing:** Everything lives in process memory. `kill -9` the +process mid-plan and the plan never happened — except OpenAI billed you for +it. Persistence was bolted onto *agents* but not onto *executions*, which is +where the money is. + +**Worked well:** `continue_on_failure` semantics and explicit state +transitions — a real state machine, easy to persist. Make it boring. Boring +survives restarts. + +## 9. Sandi Metz — POODR, cheap change + +**What interests me:** Injection is everywhere; `TaskResult`/`TaskFailure` +model failure as data instead of control flow. These choices make change cheap. + +**Didn't work:** `execute_with_schema` checked `has_capability?("text_generation")` +and then *silently ignored the schema you passed it* — a method that doesn't +do what its name promises will hurt someone at 2 a.m. And `FactoryMethods` +set its DSL state only on the including class — subclass `Agent` and the DSL +quietly breaks. Inheritance debt, pre-borrowed. + +## 10. Andrew Kane (ankane) — shipper of practical ML gems + +**What I'd build:** The missing capabilities as tiny plug-ins. The README +advertises `--capabilities=text_generation,web_search`, but the shipped +`web_search` implementation returned hardcoded fake results. + +**What interests me:** `CapabilityProvider` taking a bare lambda is the whole +plugin API, and it's low-ceremony enough that people will actually write +plugins. The `api_base_url` escape hatch means local-first works today. + +--- + +## What the room agrees on + +Ten different sensibilities converge on five points, which makes them the +highest-value fixes: + +1. **Split the CLI from the library** (Jeremy, Aaron, Piotr) — thor + tty-* + shouldn't load into library consumers. *Addressed for load-time by the + Zeitwerk cleanup; a gem split remains future work.* +2. **Resolve the dual loading scheme** (Xavier, Aaron) — Zeitwerk *or* + `require_relative`, not both. *Done.* +3. **A real error hierarchy and no silent fallbacks** (Matz, Sandi, Jeremy) — + string `raise`s, the `"ollama"` token default, and `execute_with_schema` + ignoring its schema are all the same bug: failure hidden until later. + *Addressed in the Jeremy and Sandi builds.* +4. **Durability and thread-safety for the thing that costs money** (Mike, + Jeremy, Samuel) — execution state was in-memory only, globals + unsynchronized. *Addressed by `ExecutionJournal`, the assembly mutex, and + reactor composability.* +5. **The docs promise more than the code delivers** (DHH, Andrew) — either + build the missing layers or trim the architecture documents. *Partially + addressed: the fake `web_search` now has a real, pluggable backend.* + +The consensus compliment: the plan-and-execute core with result objects, +lifecycle hooks, and Async-based orchestration is genuinely good Ruby — the +bones deserved the cleanup they got here. diff --git a/docs/perspectives/field-notes/00-prologue.md b/docs/perspectives/field-notes/00-prologue.md new file mode 100644 index 0000000..e20f164 --- /dev/null +++ b/docs/perspectives/field-notes/00-prologue.md @@ -0,0 +1,38 @@ +# Prologue — before anyone could build anything + +*Field notes from the session itself, before putting on any persona.* + +Every persona's build depends on a trustworthy test suite, so the first stop +was `rake spec`. What we found shaped everything after it: + +- `rspec` reported **65 examples** in 0.06 seconds. A `--dry-run` reported + **474 examples**. The suite wasn't fast; it was being killed. +- The culprit: `spec/agentic/cli_spec.rb` invoked + `agent create --role=... --instructions=...`, but the command requires + `--purpose`. Thor's `exit_on_failure?` is `true`, so Thor called `exit(1)` + — inside the rspec process — and the run silently truncated at whatever + example happened to be number 65. +- Repairing that revealed **12 latent failures** that had presumably been + red for a long time, invisible because the process died before reaching + them. + +The latent failures were real bugs, not stale assertions: + +1. `Agentic::Agent.new do |a| ... end` in the CLI and in specs — but + `Agent#initialize` never yields, so the configuration block was silently + discarded. Agents were being created with nil roles and purposes. + (`Agent.build` is the yielding constructor.) +2. `PersistentAgentStore#store` generated an ID for id-less agents but never + assigned it back, so storing the same agent twice created two unrelated + agents instead of two versions of one. +3. `PersistentAgentStore#list_all` didn't accept the `all(filter: {...})` + calling convention its own ADR-015 documents. +4. `Agentic.register_capability` / `.assemble_agent` used module ivars + directly, so the public readers existed but were bypassed — and specs that + stubbed the readers were stubbing nothing. +5. Capability inference required the literal string `data_analysis` to appear + in a task description; "Analyze the data" matched nothing. + +**Lesson for the room:** a test suite that exits early is worse than a failing +one — it converts red to green by truncation. If your CI passed on this +codebase, your CI was measuring how far rspec got before Thor shot it. From 11c94070c61f17c3174b46e1e08d3ed844c352f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:00:45 +0000 Subject: [PATCH 03/46] refactor: make Zeitwerk the single code loader for the gem Remove all require_relative calls for Zeitwerk-managed constants (nine in lib/agentic.rb plus the rest scattered through lib/), add the missing 'ui' => 'UI' inflection that the manual requires had been masking, and require thor from each CLI file that reopens class CLI < Thor so the directory is loadable file-by-file. The CLI (and its Thor/tty-* dependency stack) is no longer eagerly loaded by the library entrypoint; exe/agentic reaches Agentic::CLI through a normal autoload. Measured: require 'agentic' drops from ~272ms to ~14ms and from 612 to 186 loaded features. Part of the Rubyist-perspectives build series (Xavier Noria persona); field notes in docs/perspectives/field-notes/04-fxn.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/04-fxn.md | 53 +++++++++++++++++++ lib/agentic.rb | 23 ++++---- lib/agentic/agent.rb | 3 -- lib/agentic/capabilities.rb | 2 - lib/agentic/capabilities/examples.rb | 4 -- lib/agentic/cli.rb | 1 - lib/agentic/cli/capabilities.rb | 2 + lib/agentic/cli/execution_observer.rb | 2 + lib/agentic/default_agent_provider.rb | 3 -- lib/agentic/extension.rb | 4 -- lib/agentic/learning.rb | 4 -- lib/agentic/llm_client.rb | 4 -- lib/agentic/llm_response.rb | 3 -- lib/agentic/plan_execution_result.rb | 2 - lib/agentic/plan_orchestrator.rb | 3 -- lib/agentic/task.rb | 3 -- lib/agentic/task_execution_result.rb | 2 - lib/agentic/task_planner.rb | 5 -- .../verification/llm_verification_strategy.rb | 3 -- .../schema_verification_strategy.rb | 3 -- 20 files changed, 66 insertions(+), 63 deletions(-) create mode 100644 docs/perspectives/field-notes/04-fxn.md diff --git a/docs/perspectives/field-notes/04-fxn.md b/docs/perspectives/field-notes/04-fxn.md new file mode 100644 index 0000000..4749c97 --- /dev/null +++ b/docs/perspectives/field-notes/04-fxn.md @@ -0,0 +1,53 @@ +# Field notes — Xavier Noria (fxn) + +*Build: make Zeitwerk the single code loader for the gem.* + +## What I did + +- Deleted all 37 `require_relative` calls that pointed at Zeitwerk-managed + files: nine in `lib/agentic.rb` and the rest scattered through `lib/` + (`agent.rb`, `plan_orchestrator.rb`, `task.rb`, the verification + strategies, …). Constants are now resolved by the loader, as they should be. +- Stopped eager-requiring the CLI from the library entrypoint. The + `do_not_eager_load` on `lib/agentic/cli` was already correct in spirit — + but the very next lines required those files by hand, defeating it. + `exe/agentic` reaches `Agentic::CLI` through a normal autoload. +- Added `require "thor"` to the two CLI files that reopen + `class CLI < Thor`, so each file in that directory is loadable on its own. + Requiring *external* dependencies at the top of the file that needs them is + the correct pattern; requiring *siblings* is not. + +## What I found while doing it + +- The comment justifying the requires — "Thor requires subcommands to be + loaded before they're referenced" — was a misdiagnosis. Thor's `subcommand` + takes a constant; referencing the constant triggers the autoload. The one + real loading bug was elsewhere: `lib/agentic/ui.rb` defines `Agentic::UI`, + but the inflector only knew about `cli`. Every reference to `Agentic::UI` + worked *only because* of the manual require. Remove the crutch and the + misconfiguration surfaces immediately: `NameError: uninitialized constant + Agentic::Ui`. This is the recurring lesson: mixed loading doesn't just + offend taste, it **masks** configuration errors. +- `Zeitwerk::Loader.eager_load_all` now passes, which is the real proof that + every file/constant pair in the project is coherent. I'd suggest adding + exactly that as a spec — it's the cheapest CI guard Zeitwerk offers. + +## Measured result + +| | before | after | +|---|---|---| +| `require "agentic"` | ~272 ms | ~14 ms | +| `$LOADED_FEATURES` after require | 612 | 186 | + +A 19× faster require, and library consumers no longer load Thor, six tty-* +gems, and Pastel to use a `TaskPlanner`. Aaron will want these numbers for +his benchmark; he can have them. + +## What worked well / what didn't + +- **Well:** the file naming was already 100% conventional. Not one file + needed renaming — only the inflector entry for `UI`. Whoever laid out this + tree had internalized the conventions even while bypassing the loader. +- **Didn't:** `spec/spec_helper.rb` requires the whole gem for every spec, so + nobody noticed the library couldn't autoload on its own. Fast requires also + make `bin/console` start instantly, which is where Matz is headed next. diff --git a/lib/agentic.rb b/lib/agentic.rb index 3712104..536bf72 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -1,30 +1,25 @@ # frozen_string_literal: true require "zeitwerk" + +# Zeitwerk is the single code loader for this gem: every constant under +# Agentic:: is autoloaded on first reference, including the CLI, so +# library consumers never pay for Thor or the tty-* UI stack at require +# time. Files must not require_relative their siblings - reference the +# constant and let the loader resolve it. loader = Zeitwerk::Loader.for_gem # Configure Zeitwerk to handle the CLI class name properly loader.inflector.inflect( - "cli" => "CLI" + "cli" => "CLI", + "ui" => "UI" ) -# Configure paths that need to be eager loaded or excluded from Zeitwerk +# The CLI is only autoloaded on demand (exe/agentic), never eager loaded loader.do_not_eager_load("#{__dir__}/agentic/cli") loader.setup -# Explicitly require Thor-related components to avoid Zeitwerk issues with Thor -# Thor requires subcommands to be loaded before they're referenced -require_relative "agentic/ui" -require_relative "agentic/default_agent_provider" -require_relative "agentic/cli" -require_relative "agentic/cli/execution_observer" -require_relative "agentic/extension" -require_relative "agentic/capabilities" -require_relative "agentic/agent_assembly_engine" -require_relative "agentic/llm_assisted_composition_strategy" -require_relative "agentic/task_output_schemas" - module Agentic class Error < StandardError; end diff --git a/lib/agentic/agent.rb b/lib/agentic/agent.rb index 557dfa7..2d16694 100644 --- a/lib/agentic/agent.rb +++ b/lib/agentic/agent.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "llm_client" -require_relative "llm_config" - module Agentic class Agent include FactoryMethods diff --git a/lib/agentic/capabilities.rb b/lib/agentic/capabilities.rb index c98481a..923748f 100644 --- a/lib/agentic/capabilities.rb +++ b/lib/agentic/capabilities.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "capabilities/examples" - module Agentic # Namespace for capability-related functionality module Capabilities diff --git a/lib/agentic/capabilities/examples.rb b/lib/agentic/capabilities/examples.rb index b7fb516..8273290 100644 --- a/lib/agentic/capabilities/examples.rb +++ b/lib/agentic/capabilities/examples.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative "../capability_specification" -require_relative "../capability_provider" -require_relative "../agent_capability_registry" - module Agentic module Capabilities # Example capabilities for common tasks diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index df44b90..d8ba13a 100644 --- a/lib/agentic/cli.rb +++ b/lib/agentic/cli.rb @@ -3,7 +3,6 @@ require "thor" require "json" require "yaml" -require_relative "cli/capabilities" module Agentic # Command Line Interface for Agentic diff --git a/lib/agentic/cli/capabilities.rb b/lib/agentic/cli/capabilities.rb index 05f86e3..bb9f198 100644 --- a/lib/agentic/cli/capabilities.rb +++ b/lib/agentic/cli/capabilities.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "thor" + module Agentic class CLI < Thor # Command-line interface for managing capabilities diff --git a/lib/agentic/cli/execution_observer.rb b/lib/agentic/cli/execution_observer.rb index 2b619af..48fb202 100644 --- a/lib/agentic/cli/execution_observer.rb +++ b/lib/agentic/cli/execution_observer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "thor" + module Agentic class CLI < Thor # Observer that provides real-time feedback during plan execution diff --git a/lib/agentic/default_agent_provider.rb b/lib/agentic/default_agent_provider.rb index c37c306..135fdea 100644 --- a/lib/agentic/default_agent_provider.rb +++ b/lib/agentic/default_agent_provider.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "llm_client" -require_relative "llm_config" - module Agentic # Default implementation of an agent provider for use in the CLI # This provider creates agents based on agent specs in tasks diff --git a/lib/agentic/extension.rb b/lib/agentic/extension.rb index f491cb7..0cdb7a6 100644 --- a/lib/agentic/extension.rb +++ b/lib/agentic/extension.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative "extension/domain_adapter" -require_relative "extension/protocol_handler" -require_relative "extension/plugin_manager" - module Agentic # The Extension module provides extensibility points for the Agentic framework. # It includes three main components: diff --git a/lib/agentic/learning.rb b/lib/agentic/learning.rb index 7d4a356..31acf46 100644 --- a/lib/agentic/learning.rb +++ b/lib/agentic/learning.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative "learning/execution_history_store" -require_relative "learning/pattern_recognizer" -require_relative "learning/strategy_optimizer" - module Agentic # The Learning module provides components for capturing execution history, # recognizing patterns, and optimizing strategies based on feedback and metrics. diff --git a/lib/agentic/llm_client.rb b/lib/agentic/llm_client.rb index 818edf2..e8dab19 100644 --- a/lib/agentic/llm_client.rb +++ b/lib/agentic/llm_client.rb @@ -2,10 +2,6 @@ require "openai" require "net/http" -require_relative "llm_response" -require_relative "errors/llm_error" -require_relative "retry_handler" -require_relative "retry_config" module Agentic # Generic wrapper for LLM API clients diff --git a/lib/agentic/llm_response.rb b/lib/agentic/llm_response.rb index 4a48c65..52c2a54 100644 --- a/lib/agentic/llm_response.rb +++ b/lib/agentic/llm_response.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "errors/llm_error" -require_relative "generation_stats" - module Agentic # Value object representing a response from an LLM class LlmResponse diff --git a/lib/agentic/plan_execution_result.rb b/lib/agentic/plan_execution_result.rb index 4831e06..a60dddf 100644 --- a/lib/agentic/plan_execution_result.rb +++ b/lib/agentic/plan_execution_result.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "task_execution_result" - module Agentic # Value object representing the execution result of a plan class PlanExecutionResult diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index 561f5ff..2cbb30c 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -5,9 +5,6 @@ require "async" require "async/barrier" require "async/semaphore" -require_relative "task_failure" -require_relative "task_execution_result" -require_relative "plan_execution_result" module Agentic # Orchestrates the execution of tasks in a plan, handling dependencies and concurrency diff --git a/lib/agentic/task.rb b/lib/agentic/task.rb index dcade6a..3d188ff 100644 --- a/lib/agentic/task.rb +++ b/lib/agentic/task.rb @@ -2,9 +2,6 @@ require "securerandom" require "json" -require_relative "observable" -require_relative "task_definition" -require_relative "agent_specification" module Agentic # Represents an individual task to be executed by an agent diff --git a/lib/agentic/task_execution_result.rb b/lib/agentic/task_execution_result.rb index a621db8..f9e6b4f 100644 --- a/lib/agentic/task_execution_result.rb +++ b/lib/agentic/task_execution_result.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "task_failure" - module Agentic # Value object representing the execution result of a task class TaskExecutionResult diff --git a/lib/agentic/task_planner.rb b/lib/agentic/task_planner.rb index 4926c1c..972187c 100644 --- a/lib/agentic/task_planner.rb +++ b/lib/agentic/task_planner.rb @@ -1,10 +1,5 @@ # frozen_string_literal: true -require_relative "execution_plan" -require_relative "agent_specification" -require_relative "task_definition" -require_relative "expected_answer_format" - module Agentic # Handles the task planning process for Agentic using LLM # diff --git a/lib/agentic/verification/llm_verification_strategy.rb b/lib/agentic/verification/llm_verification_strategy.rb index 003dbe2..9adbd9f 100644 --- a/lib/agentic/verification/llm_verification_strategy.rb +++ b/lib/agentic/verification/llm_verification_strategy.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "verification_strategy" -require_relative "verification_result" - module Agentic module Verification # Verifies task results using an LLM diff --git a/lib/agentic/verification/schema_verification_strategy.rb b/lib/agentic/verification/schema_verification_strategy.rb index b3c77b4..3b93862 100644 --- a/lib/agentic/verification/schema_verification_strategy.rb +++ b/lib/agentic/verification/schema_verification_strategy.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "verification_strategy" -require_relative "verification_result" - module Agentic module Verification # Verifies task results against a schema From cb2e1aed677b73878d0b8c2c6c4ca53bcacf08e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:01:44 +0000 Subject: [PATCH 04/46] docs: add the three-line haiku agent example (Matz persona) A no-network, no-API-key example showing an agent, a lambda-backed capability, and a result on one screen. Field notes in docs/perspectives/field-notes/01-matz.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/01-matz.md | 42 +++++++++++++++++++ examples/haiku_agent.rb | 51 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 docs/perspectives/field-notes/01-matz.md create mode 100644 examples/haiku_agent.rb diff --git a/docs/perspectives/field-notes/01-matz.md b/docs/perspectives/field-notes/01-matz.md new file mode 100644 index 0000000..1e8f6c6 --- /dev/null +++ b/docs/perspectives/field-notes/01-matz.md @@ -0,0 +1,42 @@ +# Field notes — Yukihiro "Matz" Matsumoto + +*Build: `examples/haiku_agent.rb` — the three-line agent.* + +## What I did + +I did what I always do with a new gem: opened a console and tried to write +the smallest program that makes me smile. It became `examples/haiku_agent.rb` +— an agent in three lines, a capability as a lambda, a poem as the result. +It runs with no API key, because a capability is just a callable and the +framework doesn't insist on a network to be understood. That is a very good +property. Programs you can understand offline are programs you can trust. + +## What made me happy + +- `Agent.build do |a| ... end` — the builder block reads like Ruby breathing. +- `CapabilityProvider.new(implementation: ->(inputs) { ... })` — the entire + extension story is "hand me a callable." No base class to inherit, no + interface to declare. This is the principle of least surprise applied to + plugins. +- The `StructuredOutputs::Schema` DSL (`s.string :name, enum: [...]`) feels + like it grew here rather than being transplanted from JSON Schema. + +## What made me pause + +- My poem arrived wrapped in bureaucracy: eight lines of + `INFO: Registered capability: ...` before three lines of haiku. A library + that speaks when not spoken to is like a friend who narrates their own + helpfulness. (Jeremy says he will fix the default logger. Good.) +- I wrote `poet.execute_capability("haiku", ...)` but I first tried + `poet.execute(...)` and `task.perform(poet)` — three verbs for one idea. + The objects should agree on a sentence structure. My suggestion: the agent + is the subject. `poet.perform(task)`. Subjects act; objects receive. +- `add_capability` raises `"Capability not found: haiku"` as a plain + `RuntimeError` if you forget to register first. I forgot, so I met it. + A `Agentic::CapabilityNotFoundError` would have told me *who* was + complaining. (Sandi has opinions here too.) + +## Verdict + +Three lines to an agent, one screen to the whole idea. The gem passes the +happiness test at small scale — now it should pass it at every scale. diff --git a/examples/haiku_agent.rb b/examples/haiku_agent.rb new file mode 100644 index 0000000..5cee845 --- /dev/null +++ b/examples/haiku_agent.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# The three-line agent. Run me with no API key at all: +# +# bundle exec ruby examples/haiku_agent.rb +# +# An agent, a capability, a result - each expressed the way Ruby wants +# to express it: a block, a lambda, a hash. Nothing here talks to a +# network; capabilities are just callables, so the whole plan-and-execute +# idea is graspable in one screen. + +require_relative "../lib/agentic" + +# 1. An agent in three lines +poet = Agentic::Agent.build do |a| + a.name = "Basho" + a.role = "Haiku poet" +end + +# 2. A capability is a specification plus any callable +haiku = Agentic::CapabilitySpecification.new( + name: "haiku", + description: "Compose a haiku about a topic", + version: "1.0.0", + inputs: {topic: {type: "string", required: true}}, + outputs: {poem: {type: "string"}} +) + +brush = Agentic::CapabilityProvider.new( + capability: haiku, + implementation: ->(inputs) { + {poem: [ + "#{inputs[:topic].capitalize} at first light", + "an old pond holds the whole sky", + "ruby leaves drift down" + ].join("\n")} + } +) + +Agentic.register_capability(haiku, brush) +poet.add_capability("haiku") + +# 3. Ask the poet for a poem +puts poet.execute_capability("haiku", {topic: "autumn"})[:poem] + +# And when you do have an API key, the same agent, the same message, +# a real LLM - only the provider changes: +# +# Agentic.configure { |c| c.access_token = ENV["OPENAI_ACCESS_TOKEN"] } +# plan = Agentic::TaskPlanner.new("Write a haiku about autumn").plan +# puts plan.to_s From 2662932ab643df8a5ccc011af098429fdacccd0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:03:41 +0000 Subject: [PATCH 05/46] feat: add Agentic.run - plan and execute a goal in one call Agentic.run(goal, model:, concurrency:) compresses the planner -> task definitions -> orchestrator -> provider pipeline into the single method most library users actually want, mirroring what the CLI's execute_plan_immediately already did internally. Part of the Rubyist-perspectives build series (DHH persona); field notes in docs/perspectives/field-notes/02-dhh.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/02-dhh.md | 39 ++++++++++++++++ lib/agentic.rb | 30 +++++++++++++ spec/agentic_run_spec.rb | 60 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 docs/perspectives/field-notes/02-dhh.md create mode 100644 spec/agentic_run_spec.rb diff --git a/docs/perspectives/field-notes/02-dhh.md b/docs/perspectives/field-notes/02-dhh.md new file mode 100644 index 0000000..0130bd1 --- /dev/null +++ b/docs/perspectives/field-notes/02-dhh.md @@ -0,0 +1,39 @@ +# Field notes — DHH + +*Build: `Agentic.run("goal")` — conceptual compression in one method.* + +## What I did + +Added `Agentic.run(goal, model: nil, concurrency: 5)`. Plan the goal, build +the tasks, execute them, return the result. It's fourteen lines and it is the +API 80% of users actually want: + +```ruby +result = Agentic.run("Summarize this week's support tickets") +``` + +That's the whole program. No `TaskPlanner`, no `PlanOrchestrator`, no +`DefaultAgentProvider`, no `LlmConfig` — those all still exist and you can +graduate to them when you need dependency-ordered tasks or lifecycle hooks. +But you shouldn't have to meet five classes to say one sentence. + +## What I found while doing it + +- The pieces composed *cleanly*. Planner → task definitions → tasks → + orchestrator → result took no glue-hacking at all, which tells you the + underlying design is better than its own surface suggests. The framework + had a great one-liner in it all along; nobody had written it. +- The CLI already contained this exact code — `execute_plan_immediately` in + `cli.rb` does plan → tasks → orchestrator — but it was buried in a Thor + class where no library user could reach it. When your command-line tool has + a better API than your library, your library is under-extracted. +- Fourteen lines, and five of them are the `Task.new(...)` ceremony because + `TaskDefinition` (what the planner emits) and `Task` (what the orchestrator + runs) are near-identical twins with no conversion method between them. + `task_def.to_task` is begging to exist. + +## What I'd do next + +Delete the vaporware sections from the architecture documents and make +`Agentic.run` the first code sample in the README. The demo is the product. +The `MetaLearningSystem` is not the product. Ship the sentence. diff --git a/lib/agentic.rb b/lib/agentic.rb index 536bf72..eea4202 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -56,6 +56,36 @@ def self.client(config) LlmClient.new(config) end + # Plan and execute a goal in one call - the 80% path + # + # @example + # result = Agentic.run("Summarize this week's support tickets") + # puts result.results.values.map(&:output) if result.successful? + # + # @param goal [String] What you want done, in plain language + # @param model [String, nil] Optional LLM model override + # @param concurrency [Integer] Maximum number of tasks to run at once + # @return [PlanExecutionResult] The structured execution results + def self.run(goal, model: nil, concurrency: 5) + config = LlmConfig.new + config.model = model if model + + plan = TaskPlanner.new(goal, config).plan + + orchestrator = PlanOrchestrator.new(concurrency_limit: concurrency) + plan.tasks.each do |task_def| + orchestrator.add_task( + Task.new( + description: task_def.description, + agent_spec: task_def.agent, + input: {} + ) + ) + end + + orchestrator.execute_plan(DefaultAgentProvider.new(config)) + end + # Initialize the core agent self-assembly components def self.initialize_agent_assembly # Create registry, store, and assembly engine if not already initialized diff --git a/spec/agentic_run_spec.rb b/spec/agentic_run_spec.rb new file mode 100644 index 0000000..8482fce --- /dev/null +++ b/spec/agentic_run_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Agentic.run" do + let(:agent_spec) do + Agentic::AgentSpecification.new( + name: "Writer", + description: "Writes things", + instructions: "Write the requested content" + ) + end + + let(:execution_plan) do + Agentic::ExecutionPlan.new( + [Agentic::TaskDefinition.new(description: "Write a summary", agent: agent_spec)], + Agentic::ExpectedAnswerFormat.new(format: "text", sections: [], length: "short") + ) + end + + let(:agent) do + instance_double(Agentic::Agent, execute: "A fine summary") + end + + let(:provider) do + instance_double(Agentic::DefaultAgentProvider, get_agent_for_task: agent) + end + + before do + planner = instance_double(Agentic::TaskPlanner, plan: execution_plan) + allow(Agentic::TaskPlanner).to receive(:new).and_return(planner) + allow(Agentic::DefaultAgentProvider).to receive(:new).and_return(provider) + end + + it "plans the goal and executes the resulting tasks in one call" do + result = Agentic.run("Summarize the support tickets") + + expect(result).to be_a(Agentic::PlanExecutionResult) + expect(result.successful?).to be true + expect(result.results.values.map(&:output)).to eq(["A fine summary"]) + end + + it "passes the model override to the planner configuration" do + Agentic.run("Summarize the support tickets", model: "gpt-4o") + + expect(Agentic::TaskPlanner).to have_received(:new) do |goal, config| + expect(goal).to eq("Summarize the support tickets") + expect(config.model).to eq("gpt-4o") + end + end + + it "respects the concurrency option" do + orchestrator = Agentic::PlanOrchestrator.new + allow(Agentic::PlanOrchestrator).to receive(:new).and_return(orchestrator) + + Agentic.run("Summarize the support tickets", concurrency: 2) + + expect(Agentic::PlanOrchestrator).to have_received(:new).with(concurrency_limit: 2) + end +end From 59251e89b6938ce0f3b323c304eda3f8c3416750 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:06:07 +0000 Subject: [PATCH 06/46] feat: add boot benchmark and make agent assembly init thread-safe benchmark/boot.rb measures wall time, allocations, and loaded-file count for requiring the library, autoloading the CLI, and initializing agent assembly - each in a fresh subprocess. Agentic.initialize_agent_assembly previously memoized global state with a bare nil check; concurrent callers could each build their own PersistentAgentStore against the same index file. It now uses a mutex with a double-checked re-entry, and assigns the flag ivar last so lock-skipping readers never observe a half-built system. Part of the Rubyist-perspectives build series (Aaron Patterson persona); field notes in docs/perspectives/field-notes/03-tenderlove.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- benchmark/boot.rb | 40 ++++++++++++++ .../perspectives/field-notes/03-tenderlove.md | 53 +++++++++++++++++++ lib/agentic.rb | 25 +++++++-- spec/agentic_assembly_thread_safety_spec.rb | 51 ++++++++++++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 benchmark/boot.rb create mode 100644 docs/perspectives/field-notes/03-tenderlove.md create mode 100644 spec/agentic_assembly_thread_safety_spec.rb diff --git a/benchmark/boot.rb b/benchmark/boot.rb new file mode 100644 index 0000000..3784cc2 --- /dev/null +++ b/benchmark/boot.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Boot-time benchmark: what does it cost to require this gem? +# +# bundle exec ruby benchmark/boot.rb +# +# Each scenario runs in a fresh subprocess so measurements don't +# contaminate each other. Wall time, object allocations, and the number +# of loaded files tell three different stories: +# - wall time is what your app's boot feels like +# - allocations approximate the parse/define work done +# - $LOADED_FEATURES is how much of the world you dragged in + +require "rbconfig" + +LIB = File.expand_path("../lib", __dir__) + +SCENARIOS = { + "baseline (empty ruby)" => "", + "require \"agentic\"" => 'require "agentic"', + "... + Agentic::CLI (thor, tty-*)" => 'require "agentic"; Agentic::CLI', + "... + agent assembly init" => 'require "agentic"; Agentic.logger.level = :error; Agentic.initialize_agent_assembly' +}.freeze + +def measure(label, code) + script = <<~RUBY + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + allocated_before = GC.stat(:total_allocated_objects) + #{code} + elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000 + allocated = GC.stat(:total_allocated_objects) - allocated_before + puts format("%-36s %9.1f ms %12d objects %6d features", + #{label.inspect}, elapsed_ms, allocated, $LOADED_FEATURES.size) + RUBY + system(RbConfig.ruby, "-I", LIB, "-e", script) || abort("scenario failed: #{label}") +end + +puts format("%-36s %12s %20s %15s", "scenario", "wall", "allocations", "loaded files") +puts "-" * 88 +SCENARIOS.each { |label, code| measure(label, code) } diff --git a/docs/perspectives/field-notes/03-tenderlove.md b/docs/perspectives/field-notes/03-tenderlove.md new file mode 100644 index 0000000..53a9f94 --- /dev/null +++ b/docs/perspectives/field-notes/03-tenderlove.md @@ -0,0 +1,53 @@ +# Field notes — Aaron Patterson (tenderlove) + +*Build: `benchmark/boot.rb` + a mutex for the global that needed one.* + +## What I did + +Two things, because you can't fix what you can't measure and you shouldn't +measure what you won't fix: + +1. **`benchmark/boot.rb`** — each scenario in a fresh subprocess, reporting + wall time, object allocations, and `$LOADED_FEATURES` count. +2. **Made `Agentic.initialize_agent_assembly` thread-safe.** It memoized four + pieces of global state with a bare `unless @ivar` check. Two threads walk + in, both see nil, both build a `PersistentAgentStore`, and now you have + two objects that both think they own `~/.agentic/agents/index.json`. + Classic check-then-act. Mutex, double-checked re-entry, and — the subtle + part — the flag ivar is now assigned *last*, so a thread that skips the + lock can never observe a half-built system. I have fixed this exact bug in + Rails so many times I could type it with my chin. + +## The numbers (Ruby 3.3.6, this machine) + +| scenario | wall | allocations | loaded files | +|---|---|---|---| +| baseline (empty ruby) | ~0 ms | 4 | 156 | +| `require "agentic"` | 14.5 ms | 11,791 | 186 | +| + `Agentic::CLI` (thor, tty-*) | 41.2 ms | 39,948 | 251 | +| + agent assembly init | 30.4 ms | 29,375 | 205 | + +Before Xavier's loader cleanup, that first row was **272 ms and 612 files** — +every library consumer paid the full CLI row on every boot. Now the tty-* +tax is only charged to people actually running the CLI, which is the whole +point of autoloading. + +## Things I noticed while in there + +- The assembly init allocates ~29k objects, most of it JSON-parsing the + agent index and registering seven standard capabilities. It's lazy now, so + nobody pays it until they touch capabilities. Good. Keep it lazy. +- `Agentic.logger` defaults to `$stdout` at **debug** level. My benchmark had + to set `level = :error` just to keep its own output readable. A library + printing INFO into its host's stdout is how you end up in *my* terminal, + and I will find you. (Jeremy is on it.) +- Nothing here is CPU-bound enough for YJIT to matter; your latency budget + is 99.9% OpenAI. But boot time and allocations are paid by every process, + network or not — which is why they're the right thing to benchmark. + +## Verdict + +The gem boots fast now, the global init is safe, and there's a benchmark to +keep both honest. Also I found a filesystem race in the agent store's +read-modify-write of `index.json`, but Perham gets paid to worry about +durability, so I left him a note. It's his turn. diff --git a/lib/agentic.rb b/lib/agentic.rb index eea4202..9e18b8f 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -86,17 +86,32 @@ def self.run(goal, model: nil, concurrency: 5) orchestrator.execute_plan(DefaultAgentProvider.new(config)) end + # Guards lazy initialization of the agent assembly system + ASSEMBLY_LOCK = Mutex.new + private_constant :ASSEMBLY_LOCK + # Initialize the core agent self-assembly components + # + # Thread-safe: concurrent callers initialize the registry, store, and + # assembly engine exactly once. def self.initialize_agent_assembly - # Create registry, store, and assembly engine if not already initialized - unless @agent_capability_registry - @agent_capability_registry = AgentCapabilityRegistry.instance - @agent_store = PersistentAgentStore.new(configuration.agent_store_path, @agent_capability_registry) - @agent_assembly_engine = AgentAssemblyEngine.new(@agent_capability_registry, @agent_store) + return if @agent_capability_registry + + ASSEMBLY_LOCK.synchronize do + # Re-check inside the lock - another thread may have won the race + return if @agent_capability_registry + + registry = AgentCapabilityRegistry.instance + @agent_store = PersistentAgentStore.new(configuration.agent_store_path, registry) + @agent_assembly_engine = AgentAssemblyEngine.new(registry, @agent_store) # Register standard capabilities Capabilities.register_standard_capabilities + # Assigned last: this ivar doubles as the initialized flag, so it must + # only become visible once the store and engine are fully built + @agent_capability_registry = registry + logger.info("Initialized agent assembly system") end end diff --git a/spec/agentic_assembly_thread_safety_spec.rb b/spec/agentic_assembly_thread_safety_spec.rb new file mode 100644 index 0000000..801804e --- /dev/null +++ b/spec/agentic_assembly_thread_safety_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +RSpec.describe "Agentic.initialize_agent_assembly" do + let(:assembly_ivars) { %i[@agent_capability_registry @agent_store @agent_assembly_engine] } + + around do |example| + ivars = %i[@agent_capability_registry @agent_store @agent_assembly_engine] + saved = ivars.to_h { |ivar| [ivar, Agentic.instance_variable_get(ivar)] } + ivars.each { |ivar| Agentic.instance_variable_set(ivar, nil) } + example.run + ensure + saved.each { |ivar, value| Agentic.instance_variable_set(ivar, value) } + end + + it "initializes the store exactly once under concurrent callers" do + Dir.mktmpdir do |dir| + allow(Agentic.configuration).to receive(:agent_store_path).and_return(dir) + + # Widen the race window so a missing lock would reliably lose the race + allow(Agentic::PersistentAgentStore).to receive(:new).and_wrap_original do |original, *args| + sleep 0.01 + original.call(*args) + end + + 8.times.map { + Thread.new { Agentic.initialize_agent_assembly } + }.each(&:join) + + expect(Agentic::PersistentAgentStore).to have_received(:new).once + expect(Agentic.agent_capability_registry).not_to be_nil + expect(Agentic.agent_store).not_to be_nil + expect(Agentic.agent_assembly_engine).not_to be_nil + end + end + + it "does not reinitialize on subsequent calls" do + Dir.mktmpdir do |dir| + allow(Agentic.configuration).to receive(:agent_store_path).and_return(dir) + + Agentic.initialize_agent_assembly + store = Agentic.agent_store + + Agentic.initialize_agent_assembly + + expect(Agentic.agent_store).to equal(store) + end + end +end From 17a36951b5e31e1312c4f7fbc7aba1e134f7d48e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:10:29 +0000 Subject: [PATCH 07/46] fix: compose PlanOrchestrator with running reactors; honor retry backoff execute_plan now runs under Sync instead of a root Async block, so it joins an existing reactor (Falcon, nested tasks) instead of spawning a detached child and reading completion timestamps that were never set. Standalone callers are unchanged. apply_retry_backoff previously spawned a detached Async task to sleep and returned immediately, so retries never observed their configured backoff; it now sleeps in the current task, which the fiber scheduler keeps non-blocking for sibling tasks. Backoff specs now assert the orchestrator actually waits instead of stubbing Async::Task.current. Part of the Rubyist-perspectives build series (Samuel Williams persona); field notes in docs/perspectives/field-notes/05-ioquatix.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/05-ioquatix.md | 65 ++++++++++++++++++++ lib/agentic/plan_orchestrator.rb | 19 +++--- spec/agentic/plan_orchestrator_retry_spec.rb | 22 +++---- spec/agentic/plan_orchestrator_spec.rb | 14 +++++ 4 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 docs/perspectives/field-notes/05-ioquatix.md diff --git a/docs/perspectives/field-notes/05-ioquatix.md b/docs/perspectives/field-notes/05-ioquatix.md new file mode 100644 index 0000000..7e039dc --- /dev/null +++ b/docs/perspectives/field-notes/05-ioquatix.md @@ -0,0 +1,65 @@ +# Field notes — Samuel Williams (ioquatix) + +*Build: make `PlanOrchestrator` compose with a running reactor.* + +## What I did + +- `execute_plan` now runs its body under `Sync` instead of a root `Async` + block. Standalone callers see no difference: `Sync` creates a reactor and + blocks until the plan completes. But called *inside* a running reactor — + under Falcon, inside another task, from `Async { ... }` — it joins the + current task tree instead of spawning a detached child and racing past it. +- Added a spec that executes a plan from within `Async { ... }` and asserts a + completed `PlanExecutionResult` comes back. +- Fixed the backoff that never waited. + +## The bug that was hiding here + +The old code was `@reactor = Async do ... end` followed immediately by code +that reads `@execution_end_time`. At the top level that works by accident, +because a root `Async` block runs to completion before returning. Inside a +reactor, `Async { }` is **asynchronous** — it returns a running task +immediately, and the next line computed `nil - nil` on timestamps that hadn't +been written yet. So the orchestrator's behavior *changed meaning* depending +on its caller's execution context. That's the worst kind of API: not wrong, +worse — conditionally right. + +`Sync` is the primitive designed for exactly this: "run this synchronously +in whatever context I'm in." One word, both worlds correct. + +## The second bug, which is my favorite + +`apply_retry_backoff` implemented its delay as: + +```ruby +Async do + Async::Task.current.sleep(delay) if delay > 0 +end +``` + +That spawns a *detached* task that sleeps, and returns immediately. The +retry proceeded with **zero delay, every time** — the backoff strategies +(constant, linear, exponential, jitter — all lovingly implemented and +unit-tested) delayed nothing. The specs passed because they stubbed +`Async::Task.current` and verified `sleep` was *called*, not that anything +*waited*. Structured concurrency lesson number one: a task nobody waits on +is a promise nobody keeps. It's now a plain `sleep(delay)` in the current +task — the fiber scheduler makes that non-blocking for siblings, which is +the entire point of running under async. + +## What worked well + +- `Async::Barrier` + `Async::Semaphore.new(parent: @barrier)` was already + the documented-correct composition, and the dependency-triggered + scheduling on top of it is a good fit for structured concurrency. +- `cancel_task` stopping the individual `Async` task is right. + +## What I'd do next + +- The lifecycle hooks are synchronous callables; an `Async::Queue` per + subscriber would give the planned "streaming observability" for free, + with back-pressure, in about thirty lines. +- `LlmClient` uses Net::HTTP via ruby-openai, which cooperates with the + fiber scheduler — but only because we're on Ruby ≥ 3.0 with async 2.x. + Document that contract; it's the reason ten concurrent tasks don't need + ten threads. diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index 2cbb30c..d65b7d6 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -66,10 +66,16 @@ def add_task(task, dependencies = []) end # Executes the plan, respecting task dependencies and concurrency limits + # + # Composes with structured concurrency: when called inside a running + # Async reactor (e.g. under Falcon or within another task) it joins the + # current reactor instead of nesting a new event loop; standalone calls + # still create their own reactor and block until the plan completes. + # # @param agent_provider [Object] An object that provides agents for task execution # @return [PlanExecutionResult] The structured execution results def execute_plan(agent_provider) - @reactor = Async do |reactor| + @reactor = Sync do |reactor| @barrier = Async::Barrier.new @semaphore = Async::Semaphore.new(@concurrency_limit, parent: @barrier) @@ -204,12 +210,11 @@ def apply_retry_backoff(task:) delay += jitter end - # Sleep if there's a delay to apply - if delay > 0 - Async do - Async::Task.current.sleep(delay) if delay > 0 - end - end + # Sleep in the current task so the retry actually waits; the async + # fiber scheduler keeps this non-blocking for sibling tasks. The old + # `Async { sleep }` spawned a detached task and returned immediately, + # so retries never observed their backoff delay. + sleep(delay) if delay > 0 end # Checks if all dependencies for a task are met diff --git a/spec/agentic/plan_orchestrator_retry_spec.rb b/spec/agentic/plan_orchestrator_retry_spec.rb index ea3ae89..400c694 100644 --- a/spec/agentic/plan_orchestrator_retry_spec.rb +++ b/spec/agentic/plan_orchestrator_retry_spec.rb @@ -65,26 +65,17 @@ def create_failure(type) end describe "backoff strategies" do - let(:async_task) { double("Async::Task") } - - before do - # Setup mocks for Async - allow(Async).to receive(:current_reactor).and_return(double("Reactor")) - allow(Async).to receive(:run).and_yield - allow(Async::Task).to receive(:current).and_return(async_task) - allow(async_task).to receive(:sleep) - end - it "applies constant backoff strategy" do orchestrator = described_class.new(retry_policy: { backoff_strategy: :constant, backoff_constant: 2 }) + allow(orchestrator).to receive(:sleep) task.retry_count = 1 orchestrator.apply_retry_backoff(task: task) - expect(async_task).to have_received(:sleep).with(a_value_within(1).of(2)) + expect(orchestrator).to have_received(:sleep).with(a_value_within(1).of(2)) end it "applies linear backoff strategy" do @@ -92,11 +83,12 @@ def create_failure(type) backoff_strategy: :linear, backoff_base: 1 }) + allow(orchestrator).to receive(:sleep) task.retry_count = 3 orchestrator.apply_retry_backoff(task: task) - expect(async_task).to have_received(:sleep).with(a_value_within(1).of(3)) + expect(orchestrator).to have_received(:sleep).with(a_value_within(1).of(3)) end it "applies exponential backoff strategy" do @@ -104,23 +96,25 @@ def create_failure(type) backoff_strategy: :exponential, backoff_base: 1 }) + allow(orchestrator).to receive(:sleep) task.retry_count = 3 orchestrator.apply_retry_backoff(task: task) # Should be approximately 1 * 2^(3-1) = 4 - expect(async_task).to have_received(:sleep).with(a_value_within(1).of(4)) + expect(orchestrator).to have_received(:sleep).with(a_value_within(1).of(4)) end it "does not apply backoff when strategy is :none" do orchestrator = described_class.new(retry_policy: { backoff_strategy: :none }) + allow(orchestrator).to receive(:sleep) task.retry_count = 1 orchestrator.apply_retry_backoff(task: task) - expect(async_task).not_to have_received(:sleep) + expect(orchestrator).not_to have_received(:sleep) end end diff --git a/spec/agentic/plan_orchestrator_spec.rb b/spec/agentic/plan_orchestrator_spec.rb index 608052f..7985b92 100644 --- a/spec/agentic/plan_orchestrator_spec.rb +++ b/spec/agentic/plan_orchestrator_spec.rb @@ -140,6 +140,20 @@ def set_failure_mode(should_fail) expect(result.task_result(task_c.id).status).to eq(:completed) end + it "joins an existing reactor instead of nesting a new event loop" do + orchestrator.add_task(task_a) + orchestrator.add_task(task_b, [task_a.id]) + + result = nil + Async do + result = orchestrator.execute_plan(agent_provider) + end.wait + + expect(result).to be_a(Agentic::PlanExecutionResult) + expect(result.status).to eq(:completed) + expect(result.task_result(task_b.id).status).to eq(:completed) + end + it "handles task failures" do failing_agent_provider = TestAgentProvider.new agent = MockAgent.new From c1c2f93cdc2f0ae8fab3617df8d63e35ee31481b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:13:06 +0000 Subject: [PATCH 08/46] feat: fail-fast LLM credential validation; quiet default logger Remove the silent 'ollama' default access token that made the CLI's check_api_token! guard unreachable and turned misconfiguration into a request-time 401. Configuration#validate! now raises Agentic::Errors::ConfigurationError at LlmClient construction when neither a token nor an api_base_url is set. Base-URL-only setups (Ollama, etc.) remain supported via an explicit placeholder token. The library logger now defaults to :warn - the CLI raises verbosity for interactive use instead of the library narrating INFO lines into every host's stdout. Part of the Rubyist-perspectives build series (Jeremy Evans persona); field notes in docs/perspectives/field-notes/06-jeremyevans.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- .../field-notes/06-jeremyevans.md | 54 +++++++++++++ lib/agentic.rb | 21 ++++- lib/agentic/cli.rb | 2 +- lib/agentic/errors/configuration_error.rb | 10 +++ lib/agentic/llm_client.rb | 11 ++- spec/agentic/configuration_spec.rb | 76 +++++++++++++++++++ spec/spec_helper.rb | 6 ++ 7 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 docs/perspectives/field-notes/06-jeremyevans.md create mode 100644 lib/agentic/errors/configuration_error.rb create mode 100644 spec/agentic/configuration_spec.rb diff --git a/docs/perspectives/field-notes/06-jeremyevans.md b/docs/perspectives/field-notes/06-jeremyevans.md new file mode 100644 index 0000000..7ce3633 --- /dev/null +++ b/docs/perspectives/field-notes/06-jeremyevans.md @@ -0,0 +1,54 @@ +# Field notes — Jeremy Evans + +*Build: fail-fast credential validation and library logging etiquette.* + +## What I did + +- Removed the `"ollama"` default access token. `Configuration#access_token` + is now the environment variables or nil — no invented credential. +- Added `Configuration#validate!` and `Agentic::Errors::ConfigurationError`. + `LlmClient.new` validates at construction: no token and no base URL means + you find out **now**, with a message listing the three ways to fix it — + not twenty minutes later as a bare 401 from a host you didn't know you + were talking to. +- Base-URL-only setups (Ollama and friends) remain first-class: they pass + validation and get an explicit `"local"` placeholder token, because local + endpoints ignore it. The difference from before is that this is now a + *decision written in code with a comment*, not a magic string in a + default. +- Default logger level is now `:warn`. A library that prints + `INFO: Registered capability: ...` eight times into its host's stdout is + taking liberties. The CLI can raise verbosity for interactive use; that's + its prerogative, not the library's default. + +## What I found while doing it + +The best part: `cli.rb` already had `check_api_token!`, which raises a +helpful boxed error `unless Agentic.configuration.access_token`. Dead code. +The `"ollama"` default meant `access_token` could **never** be nil, so the +guard never fired, so every misconfigured user sailed straight past the +helpful error into the confusing one. A fail-fast check and a +silently-succeeding default cannot coexist; the default always wins. Delete +the default and the check started working for the first time — I just had to +teach it that a base URL is also a valid answer. + +Also worth stating plainly: `filter_sensitive_data` in the spec helper was +dutifully scrubbing the string `"ollama"` out of VCR cassettes. Security +theater for a credential that never existed. + +## What I did not do (yet), and would + +- The gemspec still ships thor + six tty-* gems + ostruct to every library + consumer. The Zeitwerk cleanup means they no longer *load*, which fixes + the runtime cost, but they still *install*. The real fix is an + `agentic-cli` gem. That's a release-process decision, not a patch, so I + left it as a recommendation. +- `ExecutionHistoryStore` does read-modify-write on JSON files with no file + locking, under an orchestrator whose whole job is concurrency. Perham's + journal (see his notes) is the model; the history store should follow it. + +## Verdict + +Errors moved from request time to boot time, the fake credential is gone, +and the library stopped talking over its host. Correctness is mostly the +discipline of refusing to guess. diff --git a/lib/agentic.rb b/lib/agentic.rb index 9e18b8f..07ed2d5 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -27,16 +27,33 @@ class << self attr_accessor :logger end - self.logger ||= Logger.new($stdout, level: :debug) + # Library etiquette: quiet by default. The CLI raises verbosity for + # interactive use; library consumers opt in via Agentic.logger.level= + self.logger ||= Logger.new($stdout, level: :warn) class Configuration attr_accessor :access_token, :agent_store_path, :api_base_url def initialize - @access_token = ENV["OPENAI_ACCESS_TOKEN"] || ENV["AGENTIC_API_TOKEN"] || "ollama" + @access_token = ENV["OPENAI_ACCESS_TOKEN"] || ENV["AGENTIC_API_TOKEN"] @agent_store_path = ENV["AGENTIC_AGENT_STORE_PATH"] || File.join(Dir.home, ".agentic", "agents") @api_base_url = ENV["AGENTIC_API_BASE_URL"] || ENV["OPENAI_BASE_URL"] end + + # Verifies that the configuration can reach an LLM: either an access + # token (hosted APIs) or a custom base URL (local endpoints such as + # Ollama, which accept any token). + # + # @return [Configuration] self, for chaining + # @raise [Errors::ConfigurationError] when no credentials are configured + def validate! + return self if access_token || api_base_url + + raise Errors::ConfigurationError, + "No LLM credentials configured. Set OPENAI_ACCESS_TOKEN (or " \ + "AGENTIC_API_TOKEN), configure an api_base_url for a local " \ + "endpoint, or use Agentic.configure { |c| c.access_token = ... }" + end end class << self diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index d8ba13a..65da564 100644 --- a/lib/agentic/cli.rb +++ b/lib/agentic/cli.rb @@ -811,7 +811,7 @@ def configure_logging # Checks for API token and raises error if not configured def check_api_token! - unless Agentic.configuration.access_token + unless Agentic.configuration.access_token || Agentic.configuration.api_base_url error_box = UI.box( "Configuration Error", "No OpenAI API token configured.\n\n" \ diff --git a/lib/agentic/errors/configuration_error.rb b/lib/agentic/errors/configuration_error.rb new file mode 100644 index 0000000..52bc8cb --- /dev/null +++ b/lib/agentic/errors/configuration_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Agentic + module Errors + # Raised when the library is asked to talk to an LLM without usable + # credentials or endpoint configuration. Raised at client construction + # time so misconfiguration fails at boot, not at request time. + class ConfigurationError < StandardError; end + end +end diff --git a/lib/agentic/llm_client.rb b/lib/agentic/llm_client.rb index e8dab19..e069791 100644 --- a/lib/agentic/llm_client.rb +++ b/lib/agentic/llm_client.rb @@ -16,11 +16,16 @@ class LlmClient # @param config [LlmConfig] The configuration for the LLM # @param retry_config [RetryConfig, Hash] Configuration for the retry handler def initialize(config, retry_config = {}) - client_options = {access_token: Agentic.configuration.access_token} + configuration = Agentic.configuration + configuration.validate! + + # Local endpoints (Ollama, etc.) ignore the token but the client + # requires one, so send an explicit placeholder rather than nil + client_options = {access_token: configuration.access_token || "local"} # Add custom base URL if configured (for Ollama, etc.) - if Agentic.configuration.api_base_url - client_options[:uri_base] = Agentic.configuration.api_base_url + if configuration.api_base_url + client_options[:uri_base] = configuration.api_base_url end @client = OpenAI::Client.new(client_options) diff --git a/spec/agentic/configuration_spec.rb b/spec/agentic/configuration_spec.rb new file mode 100644 index 0000000..a445bd3 --- /dev/null +++ b/spec/agentic/configuration_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Agentic::Configuration do + around do |example| + original = Agentic.instance_variable_get(:@configuration) + example.run + ensure + Agentic.instance_variable_set(:@configuration, original) + end + + def fresh_configuration(token: nil, base_url: nil) + config = nil + without_env("OPENAI_ACCESS_TOKEN", "AGENTIC_API_TOKEN", "AGENTIC_API_BASE_URL", "OPENAI_BASE_URL") do + config = described_class.new + end + config.access_token = token + config.api_base_url = base_url + config + end + + def without_env(*keys) + saved = keys.to_h { |key| [key, ENV.delete(key)] } + yield + ensure + saved.each { |key, value| ENV[key] = value if value } + end + + describe "#validate!" do + it "raises ConfigurationError when neither token nor base URL is set" do + config = fresh_configuration + + expect { config.validate! }.to raise_error( + Agentic::Errors::ConfigurationError, /No LLM credentials configured/ + ) + end + + it "passes with an access token" do + config = fresh_configuration(token: "sk-real") + expect(config.validate!).to eq(config) + end + + it "passes with only a base URL (local endpoints)" do + config = fresh_configuration(base_url: "http://localhost:11434/v1") + expect(config.validate!).to eq(config) + end + end + + describe "defaults" do + it "does not invent a placeholder token" do + config = fresh_configuration + expect(config.access_token).to be_nil + end + end + + describe "LlmClient fail-fast" do + it "raises at construction when unconfigured, not at request time" do + Agentic.instance_variable_set(:@configuration, fresh_configuration) + + expect { Agentic::LlmClient.new(Agentic::LlmConfig.new) }.to raise_error( + Agentic::Errors::ConfigurationError + ) + end + + it "constructs with a placeholder token for base-URL-only setups" do + Agentic.instance_variable_set( + :@configuration, + fresh_configuration(base_url: "http://localhost:11434/v1") + ) + + client = Agentic::LlmClient.new(Agentic::LlmConfig.new) + expect(client.client).to be_a(OpenAI::Client) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b5889d6..71a1ee6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,12 @@ require "vcr" +# Give the test environment a credential so fail-fast configuration +# validation passes without a real key; VCR intercepts all HTTP anyway +Agentic.configure do |config| + config.access_token ||= "test-token" +end + VCR.configure do |config| config.cassette_library_dir = "spec/vcr_cassettes" config.hook_into :webmock From 6811147340928567708af7f84305527ca79a1542 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:16:10 +0000 Subject: [PATCH 09/46] feat: enforce capability contracts with dry-schema CapabilityValidator compiles each CapabilitySpecification's declared inputs/outputs into memoized Dry::Schema definitions - putting the gem's existing (and previously unused) dry-schema dependency to work. Declared types are enforced, required keys required, undeclared keys still permitted. Violations raise Agentic::Errors::ValidationError carrying the capability name, the side of the contract that failed, and every violation at once, replacing the duplicated first-failure RuntimeError type checks in CapabilityProvider. Adds the previously missing CapabilityProvider spec coverage. Part of the Rubyist-perspectives build series (Piotr Solnica persona); field notes in docs/perspectives/field-notes/07-solnic.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/07-solnic.md | 57 +++++++++ lib/agentic/capability_provider.rb | 117 +----------------- lib/agentic/capability_validator.rb | 79 ++++++++++++ lib/agentic/errors/validation_error.rb | 31 +++++ spec/agentic/capability_validator_spec.rb | 137 +++++++++++++++++++++ 5 files changed, 310 insertions(+), 111 deletions(-) create mode 100644 docs/perspectives/field-notes/07-solnic.md create mode 100644 lib/agentic/capability_validator.rb create mode 100644 lib/agentic/errors/validation_error.rb create mode 100644 spec/agentic/capability_validator_spec.rb diff --git a/docs/perspectives/field-notes/07-solnic.md b/docs/perspectives/field-notes/07-solnic.md new file mode 100644 index 0000000..e2c703b --- /dev/null +++ b/docs/perspectives/field-notes/07-solnic.md @@ -0,0 +1,57 @@ +# Field notes — Piotr Solnica (solnic) + +*Build: make the declared capability contracts real, with the dry-rb +dependency the gem already had.* + +## What I did + +- Added `CapabilityValidator`: it takes a `CapabilitySpecification` and + compiles the declared `inputs:`/`outputs:` hashes into actual + `Dry::Schema` definitions, memoized per capability. Declared types are + enforced, required keys are required, unknown keys stay permitted (a + capability may accept more than it declares — the contract is a floor, + not a ceiling). +- Added `Agentic::Errors::ValidationError` carrying `capability`, `kind` + (`:inputs`/`:outputs`), and a `violations` hash with **every** problem, + not just the first. Boundary errors should let you fix a payload in one + round trip, not one message at a time. +- `CapabilityProvider#execute` now delegates to the validator; the two + 40-line hand-rolled type-checking case statements (one for inputs, one + for outputs, near-identical twins) are gone. + +## The thing I have to say out loud + +`dry-schema` was in the gemspec. It was `require`d at the top of +`structured_outputs.rb`. And it was used **zero** times in the entire +codebase — while forty lines away, someone hand-rolled the exact +first-match, string-raising type checker that dry-schema exists to replace. +You invited dry-rb to the party and left it standing at the door. I have +now handed it a drink. + +## What I found while doing it + +- The old validator had reasonable instincts (skip undeclared keys, check + both string and symbol keys) but reported only the *first* failure, as a + `RuntimeError` with no structure — so a caller couldn't distinguish "you + sent bad inputs" from "the capability broke" without parsing prose. +- There was **no spec file for `CapabilityProvider` at all**. The contract + enforcement — the thing standing between an LLM's creative output and + your capability implementations — was untested. It has one now, including + the case I care most about: an implementation that violates its *own* + output contract gets caught too. Contracts point both ways. +- The `type?: Numeric` predicate is doing honest work: the LLM-adjacent + world is full of `"3"` where `3` was meant, and coercing silently + (`Dry::Schema.Params`) would have hidden exactly the class of bug this + layer exists to expose. I chose the strict schema deliberately. + +## What I'd do next + +- `AgentSpecification`, `TaskDefinition`, `ExpectedAnswerFormat` are still + hand-written structs with `to_h`/`from_hash` pairs. They work; they'd be + a third the code as `Dry::Struct`. But that's taste plus a dependency + decision, not a defect, so it stays a suggestion. +- The planner's LLM responses flow into `Task.new(agent_spec: )`. + The boundary between "JSON some model emitted" and "typed value object" + is precisely where dry-validation contracts earn their keep. One + `PlanContract` would let the CLI reject a malformed plan file with named + errors instead of a NoMethodError three layers deep. diff --git a/lib/agentic/capability_provider.rb b/lib/agentic/capability_provider.rb index c30062a..f08c1d1 100644 --- a/lib/agentic/capability_provider.rb +++ b/lib/agentic/capability_provider.rb @@ -13,16 +13,16 @@ class CapabilityProvider def initialize(capability:, implementation:) @capability = capability @implementation = implementation + @validator = CapabilityValidator.new(capability) end - # Execute the capability + # Execute the capability, enforcing its declared input/output contract # @param inputs [Hash] The inputs for the capability # @return [Hash] The outputs from the capability + # @raise [Errors::ValidationError] when inputs or outputs violate the specification def execute(inputs = {}) - # Validate inputs against capability specification - validate_inputs!(inputs) + @validator.validate_inputs!(inputs) - # Execute the implementation result = case @implementation when Proc @implementation.call(inputs) @@ -30,117 +30,12 @@ def execute(inputs = {}) instance = @implementation.new instance.execute(inputs) else - raise "Invalid implementation type: #{@implementation.class}" + raise ArgumentError, "Invalid implementation type: #{@implementation.class}" end - # Validate outputs against capability specification - validate_outputs!(result) + @validator.validate_outputs!(result) result end - - private - - def validate_inputs!(inputs) - # Skip validation if there are no input specifications - return unless @capability.inputs && !@capability.inputs.empty? - - # Check for required inputs - @capability.inputs.each do |name, spec| - if spec[:required] && !inputs.key?(name.to_sym) && !inputs.key?(name.to_s) - raise "Missing required input: #{name}" - end - end - - # Validate input types (if specified) - inputs.each do |name, value| - name_sym = name.to_sym - name_str = name.to_s - - # Skip inputs that aren't in the specification - next unless @capability.inputs.key?(name_sym) || @capability.inputs.key?(name_str) - - # Get the spec for this input - input_spec = @capability.inputs[name_sym] || @capability.inputs[name_str] - - # Skip if no type is specified - next unless input_spec[:type] - - # Check type - case input_spec[:type] - when "string" - unless value.is_a?(String) - raise "Input #{name} must be a string" - end - when "number", "integer" - unless value.is_a?(Numeric) - raise "Input #{name} must be a number" - end - when "boolean" - unless value == true || value == false - raise "Input #{name} must be a boolean" - end - when "array" - unless value.is_a?(Array) - raise "Input #{name} must be an array" - end - when "object", "hash" - unless value.is_a?(Hash) - raise "Input #{name} must be an object/hash" - end - end - end - end - - def validate_outputs!(outputs) - # Skip validation if there are no output specifications or the output is nil - return unless @capability.outputs && !@capability.outputs.empty? && outputs - - # Check for required outputs - @capability.outputs.each do |name, spec| - if spec[:required] && !outputs.key?(name.to_sym) && !outputs.key?(name.to_s) - raise "Missing required output: #{name}" - end - end - - # Validate output types (if specified) - outputs.each do |name, value| - name_sym = name.to_sym - name_str = name.to_s - - # Skip outputs that aren't in the specification - next unless @capability.outputs.key?(name_sym) || @capability.outputs.key?(name_str) - - # Get the spec for this output - output_spec = @capability.outputs[name_sym] || @capability.outputs[name_str] - - # Skip if no type is specified - next unless output_spec[:type] - - # Check type - case output_spec[:type] - when "string" - unless value.is_a?(String) - raise "Output #{name} must be a string" - end - when "number", "integer" - unless value.is_a?(Numeric) - raise "Output #{name} must be a number" - end - when "boolean" - unless value == true || value == false - raise "Output #{name} must be a boolean" - end - when "array" - unless value.is_a?(Array) - raise "Output #{name} must be an array" - end - when "object", "hash" - unless value.is_a?(Hash) - raise "Output #{name} must be an object/hash" - end - end - end - end end end diff --git a/lib/agentic/capability_validator.rb b/lib/agentic/capability_validator.rb new file mode 100644 index 0000000..ba122e7 --- /dev/null +++ b/lib/agentic/capability_validator.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "dry/schema" + +module Agentic + # Enforces a capability's declared input/output contract using dry-schema. + # + # A CapabilitySpecification declares its boundary as data: + # + # inputs: {prompt: {type: "string", required: true}} + # + # This class turns that declaration into a real schema, so the types + # written in a specification are checked rather than decorative. Unknown + # keys are permitted - a capability may accept more than it declares - + # but every declared key must honor its declared type, and required keys + # must be present. + class CapabilityValidator + # @param specification [CapabilitySpecification] The capability specification + def initialize(specification) + @specification = specification + @schemas = {} + end + + # Validates inputs against the capability's declared inputs + # @param inputs [Hash] The inputs to validate + # @return [void] + # @raise [Errors::ValidationError] listing every violation + def validate_inputs!(inputs) + validate!(:inputs, @specification.inputs, inputs) + end + + # Validates outputs against the capability's declared outputs + # @param outputs [Hash, nil] The outputs to validate (nil is skipped) + # @return [void] + # @raise [Errors::ValidationError] listing every violation + def validate_outputs!(outputs) + return if outputs.nil? + + validate!(:outputs, @specification.outputs, outputs) + end + + private + + def validate!(kind, declared, values) + return if declared.nil? || declared.empty? + + result = schema_for(kind, declared).call(symbolize_keys(values)) + return if result.success? + + raise Errors::ValidationError.new( + capability: @specification.name, + kind: kind, + violations: result.errors.to_h + ) + end + + def schema_for(kind, declared) + @schemas[kind] ||= Dry::Schema.define do + declared.each do |name, definition| + definition ||= {} + key = definition[:required] ? required(name.to_sym) : optional(name.to_sym) + + case definition[:type] + when "string" then key.value(:string) + when "number", "integer" then key.value(type?: Numeric) + when "boolean" then key.value(:bool) + when "array" then key.value(:array) + when "object", "hash" then key.value(:hash) + else key.value(type?: Object) + end + end + end + end + + def symbolize_keys(values) + values.to_h { |key, value| [key.to_sym, value] } + end + end +end diff --git a/lib/agentic/errors/validation_error.rb b/lib/agentic/errors/validation_error.rb new file mode 100644 index 0000000..5acddea --- /dev/null +++ b/lib/agentic/errors/validation_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Agentic + module Errors + # Raised when a capability's inputs or outputs violate its declared + # specification. Collects every violation instead of failing on the + # first, so callers can fix a bad payload in one round trip. + class ValidationError < StandardError + # @return [String] The capability whose contract was violated + attr_reader :capability + + # @return [Symbol] Which side of the contract failed (:inputs or :outputs) + attr_reader :kind + + # @return [Hash{Symbol=>Array}] Violation messages keyed by attribute + attr_reader :violations + + # @param capability [String] The capability name + # @param kind [Symbol] :inputs or :outputs + # @param violations [Hash{Symbol=>Array}] Messages keyed by attribute + def initialize(capability:, kind:, violations:) + @capability = capability + @kind = kind + @violations = violations + + details = violations.map { |key, messages| "#{key} #{Array(messages).join(", ")}" }.join("; ") + super("Invalid #{kind} for capability '#{capability}': #{details}") + end + end + end +end diff --git a/spec/agentic/capability_validator_spec.rb b/spec/agentic/capability_validator_spec.rb new file mode 100644 index 0000000..3740f8a --- /dev/null +++ b/spec/agentic/capability_validator_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Agentic::CapabilityValidator do + let(:specification) do + Agentic::CapabilitySpecification.new( + name: "report_generation", + description: "Generates a report", + version: "1.0.0", + inputs: { + topic: {type: "string", required: true}, + depth: {type: "number"}, + options: {type: "object"} + }, + outputs: { + report: {type: "string", required: true}, + sections: {type: "array"} + } + ) + end + + subject(:validator) { described_class.new(specification) } + + describe "#validate_inputs!" do + it "accepts inputs matching the declared contract" do + expect { + validator.validate_inputs!(topic: "AI trends", depth: 3) + }.not_to raise_error + end + + it "accepts string keys" do + expect { + validator.validate_inputs!("topic" => "AI trends") + }.not_to raise_error + end + + it "permits undeclared keys" do + expect { + validator.validate_inputs!(topic: "AI trends", surprise: :fine) + }.not_to raise_error + end + + it "collects every violation into one typed error" do + expect { + validator.validate_inputs!(depth: "very", options: 42) + }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.capability).to eq("report_generation") + expect(error.kind).to eq(:inputs) + expect(error.violations.keys).to contain_exactly(:topic, :depth, :options) + expect(error.message).to include("report_generation") + } + end + end + + describe "#validate_outputs!" do + it "accepts outputs matching the declared contract" do + expect { + validator.validate_outputs!(report: "done", sections: %w[intro body]) + }.not_to raise_error + end + + it "skips validation for nil outputs" do + expect { validator.validate_outputs!(nil) }.not_to raise_error + end + + it "rejects outputs missing required keys" do + expect { + validator.validate_outputs!(sections: []) + }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.kind).to eq(:outputs) + expect(error.violations).to have_key(:report) + } + end + end + + context "with no declared contract" do + let(:specification) do + Agentic::CapabilitySpecification.new( + name: "freeform", + description: "Anything goes", + version: "1.0.0" + ) + end + + it "validates nothing" do + expect { validator.validate_inputs!(whatever: 1) }.not_to raise_error + expect { validator.validate_outputs!(whatever: 1) }.not_to raise_error + end + end +end + +RSpec.describe Agentic::CapabilityProvider do + let(:specification) do + Agentic::CapabilitySpecification.new( + name: "echo", + description: "Echoes its input", + version: "1.0.0", + inputs: {message: {type: "string", required: true}}, + outputs: {echo: {type: "string", required: true}} + ) + end + + it "executes a conforming implementation" do + provider = described_class.new( + capability: specification, + implementation: ->(inputs) { {echo: inputs[:message]} } + ) + + expect(provider.execute(message: "hello")).to eq(echo: "hello") + end + + it "rejects violating inputs before executing the implementation" do + called = false + provider = described_class.new( + capability: specification, + implementation: ->(_inputs) { + called = true + {echo: "never"} + } + ) + + expect { provider.execute(message: 42) }.to raise_error(Agentic::Errors::ValidationError) + expect(called).to be false + end + + it "rejects implementations that break their own output contract" do + provider = described_class.new( + capability: specification, + implementation: ->(_inputs) { {wrong_key: "oops"} } + ) + + expect { provider.execute(message: "hello") }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.kind).to eq(:outputs) + } + end +end From 076298c53ef6e92313972939d8c612ca5bf34335 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:18:15 +0000 Subject: [PATCH 10/46] feat: add ExecutionJournal for crash-surviving plan state An append-only JSONL journal that wires into PlanOrchestrator's lifecycle hooks: one locked, flushed, fsynced line per task/plan event, with optional chaining through existing hooks so CLI observers keep working. ExecutionJournal.replay reconstructs completed work, outputs, and failures so a resumed process can skip tasks it already paid an LLM for; retry-then-succeed collapses to completed. Part of the Rubyist-perspectives build series (Mike Perham persona); field notes in docs/perspectives/field-notes/08-mperham.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/08-mperham.md | 61 +++++++++ lib/agentic/execution_journal.rb | 140 ++++++++++++++++++++ spec/agentic/execution_journal_spec.rb | 109 +++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 docs/perspectives/field-notes/08-mperham.md create mode 100644 lib/agentic/execution_journal.rb create mode 100644 spec/agentic/execution_journal_spec.rb diff --git a/docs/perspectives/field-notes/08-mperham.md b/docs/perspectives/field-notes/08-mperham.md new file mode 100644 index 0000000..1152753 --- /dev/null +++ b/docs/perspectives/field-notes/08-mperham.md @@ -0,0 +1,61 @@ +# Field notes — Mike Perham (mperham) + +*Build: `ExecutionJournal` — the plan state that survives `kill -9`.* + +## What I did + +Added `Agentic::ExecutionJournal`: an append-only JSONL journal that plugs +into `PlanOrchestrator`'s lifecycle hooks. One JSON line per event — +`task_started`, `task_succeeded` (with output), `task_failed` (with error), +`plan_completed` — each write taken under a mutex *and* an exclusive file +lock, flushed, and fsynced before the hook returns. `ExecutionJournal.replay` +reads the file back into a `ReplayedState`: which tasks completed, what +each one produced, what failed and why. Retry-then-succeed collapses to +completed, the way an operator would expect. + +```ruby +journal = Agentic::ExecutionJournal.new(path: "orders.journal.jsonl") +orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) +# deploy hits, process dies, rerun: +state = Agentic::ExecutionJournal.replay(path: "orders.journal.jsonl") +state.completed?("task-3") # => true; do NOT pay OpenAI for it again +``` + +The hooks chain: `journal.lifecycle_hooks(observer.lifecycle_hooks)` journals +first, then delegates, so the CLI's pretty progress display and the boring +durable record coexist. Durability shouldn't cost you your spinners. + +## Why this design and not something fancier + +- **Append-only JSONL** because the failure mode of "append a line" is a + truncated last line, which replay can skip; the failure mode of + "rewrite a JSON document" (what the agent-store index does today) is a + destroyed file. +- **fsync per event** because a plan event is worth dollars. When each line + represents an LLM call you'd otherwise re-run at $0.01–$1 a pop, one + `fdatasync` is the cheapest insurance you will ever buy. If someone runs + thousand-task plans, batching is a constructor option away — start correct. +- **No new dependency.** Redis is where this ends up at scale (ask me how I + know), but a gem should offer durability before it demands infrastructure. + +## What I found while doing it + +- The lifecycle hooks are *exactly* right as an integration seam — I built + full durability without touching a line of the orchestrator. Whoever + designed those hooks earned their keep. +- The orchestrator's in-memory `@results` and the observer's save-at-the-end + `result-TIMESTAMP.json` both evaporate on crash — the file only gets + written from the `plan_completed` hook, i.e. only when nothing went wrong + enough to matter. Durability that engages only on success is a mood ring, + not a seatbelt. +- Aaron left me a note about `PersistentAgentStore#save_index` — unlocked + read-modify-write of `index.json` shared by any concurrent process. He's + right. Same medicine applies: lock, or go append-only. Left as a marked + TODO for a follow-up; it's a data-format change. + +## What I'd do next + +Idempotency keys: `task_id` is stable within a plan, so `replay` + +"skip completed tasks" gives you resume. The missing piece is the +orchestrator accepting a `skip_completed:` set so resume is one line +instead of a filter the caller writes. Small PR, big invoice savings. diff --git a/lib/agentic/execution_journal.rb b/lib/agentic/execution_journal.rb new file mode 100644 index 0000000..2403cf2 --- /dev/null +++ b/lib/agentic/execution_journal.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" + +module Agentic + # Durable, append-only journal of plan execution. + # + # Wires into PlanOrchestrator's lifecycle hooks and writes one JSON line + # per event - locked, flushed, and fsynced - so a crashed or killed + # process leaves a complete record of every task that started, finished, + # or failed, and of what each success cost to obtain. LLM calls are the + # expensive part of a plan; the journal is what lets you not pay for + # them twice. + # + # @example Journal a plan and resume after a crash + # journal = Agentic::ExecutionJournal.new(path: "orders.journal.jsonl") + # orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) + # # ... process dies mid-plan, rerun: + # state = Agentic::ExecutionJournal.replay(path: "orders.journal.jsonl") + # state.completed_task_ids # => tasks you already paid for + # state.outputs["task-1"] # => their outputs, ready to reuse + class ExecutionJournal + # Replayed journal state: everything a resuming process needs to know + ReplayedState = Struct.new( + :plan_id, :status, :completed_task_ids, :failed_task_ids, :outputs, :failures, :events, + keyword_init: true + ) do + # @param task_id [String] The task to check + # @return [Boolean] True if the journal records a success for the task + def completed?(task_id) + completed_task_ids.include?(task_id) + end + end + + # @return [String] Absolute path of the journal file + attr_reader :path + + # @param path [String] Where to write the journal (created on first event) + def initialize(path:) + @path = File.expand_path(path) + @mutex = Mutex.new + FileUtils.mkdir_p(File.dirname(@path)) + end + + # Lifecycle hooks for PlanOrchestrator, optionally chaining existing hooks + # @param hooks [Hash] Hooks to invoke after journaling (e.g. a CLI observer's) + # @return [Hash] Hooks that journal each event, then delegate + def lifecycle_hooks(hooks = {}) + { + before_task_execution: chain(hooks[:before_task_execution]) do |task_id:, task:| + record(:task_started, task_id: task_id, description: task.description) + end, + after_task_success: chain(hooks[:after_task_success]) do |task_id:, task:, result:, duration:| + record(:task_succeeded, task_id: task_id, duration: duration, output: result.output) + end, + after_task_failure: chain(hooks[:after_task_failure]) do |task_id:, task:, failure:, duration:| + record(:task_failed, task_id: task_id, duration: duration, error: failure.message, error_type: failure.type) + end, + plan_completed: chain(hooks[:plan_completed]) do |plan_id:, status:, execution_time:, tasks:, results:| + record(:plan_completed, plan_id: plan_id, status: status, execution_time: execution_time) + end + } + end + + # Appends an event to the journal - locked, flushed, and fsynced + # @param event [Symbol, String] The event name + # @param payload [Hash] Event data (must be JSON-serializable) + # @return [void] + def record(event, payload = {}) + line = JSON.generate({event: event, at: Time.now.utc.iso8601(3)}.merge(payload)) + + @mutex.synchronize do + File.open(@path, "a") do |file| + file.flock(File::LOCK_EX) + file.puts(line) + file.flush + file.fsync + end + end + end + + # Replays a journal file into resumable state + # @param path [String] The journal file to replay + # @return [ReplayedState] What completed, what failed, and what it produced + def self.replay(path:) + state = ReplayedState.new( + plan_id: nil, + status: nil, + completed_task_ids: [], + failed_task_ids: [], + outputs: {}, + failures: {}, + events: [] + ) + + return state unless File.exist?(path) + + File.foreach(path) do |line| + line = line.strip + next if line.empty? + + entry = JSON.parse(line, symbolize_names: true) + state.events << entry + + case entry[:event] + when "task_succeeded" + task_id = entry[:task_id] + state.completed_task_ids << task_id unless state.completed_task_ids.include?(task_id) + state.failed_task_ids.delete(task_id) + state.failures.delete(task_id) + state.outputs[task_id] = entry[:output] + when "task_failed" + task_id = entry[:task_id] + unless state.completed_task_ids.include?(task_id) + state.failed_task_ids << task_id unless state.failed_task_ids.include?(task_id) + state.failures[task_id] = {message: entry[:error], type: entry[:error_type]} + end + when "plan_completed" + state.plan_id = entry[:plan_id] + state.status = entry[:status]&.to_sym + end + end + + state + end + + private + + # Wraps a journaling block so an existing hook still runs afterwards + def chain(existing, &journal_block) + return journal_block unless existing + + ->(**kwargs) { + journal_block.call(**kwargs) + existing.call(**kwargs) + } + end + end +end diff --git a/spec/agentic/execution_journal_spec.rb b/spec/agentic/execution_journal_spec.rb new file mode 100644 index 0000000..b3b3c8b --- /dev/null +++ b/spec/agentic/execution_journal_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +RSpec.describe Agentic::ExecutionJournal do + let(:dir) { Dir.mktmpdir("agentic_journal_test") } + let(:path) { File.join(dir, "plan.journal.jsonl") } + let(:journal) { described_class.new(path: path) } + + after { FileUtils.remove_entry(dir) } + + describe "#record" do + it "appends one JSON line per event" do + journal.record(:task_started, task_id: "t-1") + journal.record(:task_succeeded, task_id: "t-1", output: {answer: 42}) + + lines = File.readlines(path).map { |line| JSON.parse(line) } + expect(lines.size).to eq(2) + expect(lines.first["event"]).to eq("task_started") + expect(lines.last.dig("output", "answer")).to eq(42) + expect(lines).to all(have_key("at")) + end + + it "is safe under concurrent writers" do + threads = 8.times.map do |i| + Thread.new do + 10.times { |j| journal.record(:task_started, task_id: "t-#{i}-#{j}") } + end + end + threads.each(&:join) + + lines = File.readlines(path) + expect(lines.size).to eq(80) + expect { lines.each { |line| JSON.parse(line) } }.not_to raise_error + end + end + + describe "integration with PlanOrchestrator" do + let(:agent) { double("Agent", execute: {"result" => "done"}) } + let(:provider) { double("AgentProvider", get_agent_for_task: agent) } + + it "journals task and plan lifecycle events" do + orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) + task = Agentic::Task.new( + description: "Journaled task", + agent_spec: {"instructions" => "test"}, + input: {} + ) + orchestrator.add_task(task) + + orchestrator.execute_plan(provider) + + events = File.readlines(path).map { |line| JSON.parse(line).fetch("event") } + expect(events).to eq(%w[task_started task_succeeded plan_completed]) + end + + it "chains through existing hooks instead of replacing them" do + observed = [] + hooks = journal.lifecycle_hooks( + after_task_success: ->(task_id:, task:, result:, duration:) { observed << task_id } + ) + orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: hooks) + task = Agentic::Task.new(description: "Chained", agent_spec: {"instructions" => "test"}, input: {}) + orchestrator.add_task(task) + + orchestrator.execute_plan(provider) + + expect(observed).to eq([task.id]) + end + end + + describe ".replay" do + it "returns empty state for a journal that does not exist yet" do + state = described_class.replay(path: File.join(dir, "missing.jsonl")) + + expect(state.completed_task_ids).to be_empty + expect(state.status).to be_nil + end + + it "reconstructs completed work and its outputs" do + journal.record(:task_started, task_id: "t-1") + journal.record(:task_succeeded, task_id: "t-1", output: {"answer" => 42}) + journal.record(:task_started, task_id: "t-2") + journal.record(:task_failed, task_id: "t-2", error: "boom", error_type: "StandardError") + journal.record(:plan_completed, plan_id: "p-1", status: "partial") + + state = described_class.replay(path: path) + + expect(state.plan_id).to eq("p-1") + expect(state.status).to eq(:partial) + expect(state.completed?("t-1")).to be true + expect(state.outputs["t-1"]).to eq(answer: 42) + expect(state.failed_task_ids).to eq(["t-2"]) + expect(state.failures["t-2"][:message]).to eq("boom") + end + + it "treats a retry success after failure as completed" do + journal.record(:task_failed, task_id: "t-1", error: "flaky", error_type: "TimeoutError") + journal.record(:task_succeeded, task_id: "t-1", output: {"ok" => true}) + + state = described_class.replay(path: path) + + expect(state.completed?("t-1")).to be true + expect(state.failed_task_ids).to be_empty + expect(state.failures).to be_empty + end + end +end From 32c28ffe46166151fff6ba699bff6c404c57bd77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:23:52 +0000 Subject: [PATCH 11/46] feat: honest execute_with_schema, inheritable factory DSL, named errors Agent#execute_with_schema previously preferred the text_generation capability and silently discarded the schema argument, so tasks with output schemas lost their structure guarantees without a trace. It now prefers the LLM client (which enforces the schema) and raises SchemaNotSupportedError when only capability execution is available. FactoryMethods gains an inherited hook so subclasses receive their parent's configurable attributes and assembly instructions instead of silently starting empty. Agent.from_h no longer uses the ignored-block Agent.new constructor. String raises in Agent become typed errors (CapabilityNotFoundError, AgentNotConfiguredError, Errors::LlmError). All error classes are consolidated into lib/agentic/errors.rb so referencing the Errors namespace loads every class - sibling error constants split across files were only autoloadable after their file's namesake happened to load first. Part of the Rubyist-perspectives build series (Sandi Metz persona); field notes in docs/perspectives/field-notes/09-sandimetz.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/09-sandimetz.md | 59 ++++++++++++++ lib/agentic/agent.rb | 46 ++++++----- .../{errors/llm_error.rb => errors.rb} | 77 ++++++++++++++++++- lib/agentic/errors/configuration_error.rb | 10 --- lib/agentic/errors/validation_error.rb | 31 -------- lib/agentic/factory_methods.rb | 8 ++ spec/agentic/agent_spec.rb | 61 +++++++++++++++ spec/agentic/factory_methods_spec.rb | 25 ++++++ 8 files changed, 252 insertions(+), 65 deletions(-) create mode 100644 docs/perspectives/field-notes/09-sandimetz.md rename lib/agentic/{errors/llm_error.rb => errors.rb} (68%) delete mode 100644 lib/agentic/errors/configuration_error.rb delete mode 100644 lib/agentic/errors/validation_error.rb diff --git a/docs/perspectives/field-notes/09-sandimetz.md b/docs/perspectives/field-notes/09-sandimetz.md new file mode 100644 index 0000000..94521e0 --- /dev/null +++ b/docs/perspectives/field-notes/09-sandimetz.md @@ -0,0 +1,59 @@ +# Field notes — Sandi Metz + +*Build: make the messages honest — `execute_with_schema`, factory +inheritance, and errors with names.* + +## What I did + +1. **`Agent#execute_with_schema` now does what its name promises.** The old + method checked for a `text_generation` capability first and, finding one, + executed the prompt *and silently discarded the schema you passed it*. + The caller asked for structured output and received free text with no + indication anything was ignored — and `Task#perform` routes through this + method whenever a task declares an output schema, so plans were quietly + losing their structure guarantees. Now the LLM client (which can honor + the schema) is preferred; a capability-only agent raises + `SchemaNotSupportedError` that says exactly what to do instead. A method + that can't keep its promise should decline the message, not fake it. + +2. **`FactoryMethods` survives inheritance.** The DSL stored + `configurable :role, ...` in class-level ivars set only on the including + class. Subclass `Agent` and your subclass's `build` finds `nil` where its + attributes should be — the parent's interface silently vanished. An + `inherited` hook now copies the sets down, and a subclass's additions stay + its own. If you offer a class as an extension point, subclassing it is a + message you've promised to answer. + +3. **Errors got names.** `raise "Capability not found: #{name}"` became + `CapabilityNotFoundError` (which knows its capability), plus + `SchemaNotSupportedError`, `AgentNotConfiguredError`, and LLM failures + now raise the `Errors::LlmError` the codebase already owned but wasn't + using here. `rescue => e; e.message.include?("not found")` is a stringly + dependency on prose; a named class is a dependency on a promise. + +4. Fixed `Agent.from_h` — the third occurrence of the + `Agent.new do ... end` ignored-block bug this session (after the CLI and + the integration specs). Three call sites independently guessed wrong + about the same constructor. + +## The design observation that matters + +That ignored-block bug repeating three times is the interesting finding. +When one caller misuses your API, it's their bug; when three do, it's your +interface. `Agent.new` accepting-and-ignoring a block *looks exactly like* +`Agent.build`, and Ruby won't warn. The deep fix isn't in any of the call +sites — it's making the wrong usage impossible or loud. If I kept going I'd +either have `initialize` yield (make `new` and `build` agree) or make +`new` private API. I limited myself to the visible defects; changing the +constructor contract deserves its own conversation. + +## A Zeitwerk footnote (Xavier was right) + +My first draft put the new error classes as siblings in one +`errors/agent_error.rb`. Instant lesson: Zeitwerk loads a file when its +*namesake* is referenced, so `SchemaNotSupportedError` was a NameError until +`AgentError` happened to load first — and the pre-existing `llm_error.rb` +had been playing this same load-order lottery with eight sibling classes +all along. All errors now live in one `errors.rb` behind the `Errors` +namespace constant, so referencing any of them loads all of them. +Conventions aren't decoration; they're load-bearing. diff --git a/lib/agentic/agent.rb b/lib/agentic/agent.rb index 2d16694..aad17e8 100644 --- a/lib/agentic/agent.rb +++ b/lib/agentic/agent.rb @@ -27,26 +27,32 @@ def execute(task) end # Executes a prompt with structured output schema + # + # The schema is a promise this method keeps: execution goes through the + # LLM client, which enforces structured output. An agent that cannot + # honor the schema says so instead of silently returning free text. + # # @param prompt [String] The prompt to execute # @param schema [Agentic::StructuredOutputs::Schema] The output schema # @return [Object] The structured response + # @raise [Errors::SchemaNotSupportedError] when only capability-based + # execution is available, which cannot enforce a schema + # @raise [Errors::AgentNotConfiguredError] when no execution path exists def execute_with_schema(prompt, schema) - # If the agent has a text_generation capability, use it - if has_capability?("text_generation") - # For now, text_generation capabilities don't support schemas - # Fall back to regular execution - execute_capability("text_generation", {prompt: prompt})[:response] - elsif @llm_client - # Use the configured LLM client with structured output + if @llm_client response = @llm_client.complete(build_messages(prompt), output_schema: schema) if response.successful? response.content else - raise "LLM execution failed: #{response.error.message}" + raise Errors::LlmError.new("LLM execution failed: #{response.error.message}", context: {prompt: prompt}) end + elsif has_capability?("text_generation") + raise Errors::SchemaNotSupportedError, + "A structured-output schema was requested, but this agent only has " \ + "the text_generation capability, which cannot enforce schemas. " \ + "Configure an llm_client, or call #execute for free-form output." else - # Fallback error - agent not properly configured - raise "Agent not configured with LLM capabilities. Use DefaultAgentProvider or configure llm_client directly." + raise Errors::AgentNotConfiguredError end end @@ -58,11 +64,11 @@ def add_capability(capability_name, version = nil) # Get the capability from the registry registry = AgentCapabilityRegistry.instance capability = registry.get(capability_name, version) - raise "Capability not found: #{capability_name}" unless capability + raise Errors::CapabilityNotFoundError.new(capability_name, context: "not registered") unless capability # Get the provider provider = registry.get_provider(capability_name, version) - raise "Provider not found for capability: #{capability_name}" unless provider + raise Errors::CapabilityNotFoundError.new(capability_name, context: "no provider registered") unless provider # Add to the agent's capabilities @capabilities[capability_name] = { @@ -96,7 +102,9 @@ def capability_specification(capability_name) # @param inputs [Hash] The inputs for the capability # @return [Hash] The outputs from the capability def execute_capability(capability_name, inputs = {}) - raise "Capability not available: #{capability_name}" unless @capabilities.key?(capability_name) + unless @capabilities.key?(capability_name) + raise Errors::CapabilityNotFoundError.new(capability_name, context: "not added to this agent") + end # Get the provider provider = @capabilities[capability_name][:provider] @@ -120,14 +128,13 @@ def to_h # @param hash [Hash] The hash representation # @return [Agent] The agent def self.from_h(hash) - new do |a| + # Capabilities need to be added separately after creation + # since they require the registry to be available + build do |a| a.role = hash[:role] || hash["role"] a.purpose = hash[:purpose] || hash["purpose"] a.backstory = hash[:backstory] || hash["backstory"] end - - # Note: Capabilities need to be added separately after creation - # since they require the registry to be available end private @@ -145,11 +152,10 @@ def execute_prompt(prompt) if response.successful? response.content else - raise "LLM execution failed: #{response.error.message}" + raise Errors::LlmError.new("LLM execution failed: #{response.error.message}", context: {prompt: prompt}) end else - # Fallback error - agent not properly configured - raise "Agent not configured with LLM capabilities. Use DefaultAgentProvider or configure llm_client directly." + raise Errors::AgentNotConfiguredError end end diff --git a/lib/agentic/errors/llm_error.rb b/lib/agentic/errors.rb similarity index 68% rename from lib/agentic/errors/llm_error.rb rename to lib/agentic/errors.rb index 301ea59..c4b1ec9 100644 --- a/lib/agentic/errors/llm_error.rb +++ b/lib/agentic/errors.rb @@ -1,7 +1,76 @@ # frozen_string_literal: true module Agentic + # All Agentic error classes live in this one file so that referencing + # Agentic::Errors (or any constant beneath it) loads every error class. + # Zeitwerk autoloads a file when its namesake constant is referenced; + # sibling constants scattered across files would only load alongside + # their namesakes, making `rescue Errors::LlmTimeoutError` a NameError + # lottery dependent on load order. module Errors + # Raised when the library is asked to talk to an LLM without usable + # credentials or endpoint configuration. Raised at client construction + # time so misconfiguration fails at boot, not at request time. + class ConfigurationError < StandardError; end + + # Raised when a capability's inputs or outputs violate its declared + # specification. Collects every violation instead of failing on the + # first, so callers can fix a bad payload in one round trip. + class ValidationError < StandardError + # @return [String] The capability whose contract was violated + attr_reader :capability + + # @return [Symbol] Which side of the contract failed (:inputs or :outputs) + attr_reader :kind + + # @return [Hash{Symbol=>Array}] Violation messages keyed by attribute + attr_reader :violations + + # @param capability [String] The capability name + # @param kind [Symbol] :inputs or :outputs + # @param violations [Hash{Symbol=>Array}] Messages keyed by attribute + def initialize(capability:, kind:, violations:) + @capability = capability + @kind = kind + @violations = violations + + details = violations.map { |key, messages| "#{key} #{Array(messages).join(", ")}" }.join("; ") + super("Invalid #{kind} for capability '#{capability}': #{details}") + end + end + + # Base class for agent configuration and capability errors + class AgentError < StandardError; end + + # Raised when a capability is requested that the registry or agent + # does not know about + class CapabilityNotFoundError < AgentError + # @return [String] The capability that could not be found + attr_reader :capability_name + + # @param capability_name [String] The capability that could not be found + # @param context [String, nil] Where the lookup failed + def initialize(capability_name, context: nil) + @capability_name = capability_name + message = "Capability not found: #{capability_name}" + message += " (#{context})" if context + super(message) + end + end + + # Raised when a structured-output schema is requested from an agent + # whose execution path cannot honor it + class SchemaNotSupportedError < AgentError; end + + # Raised when an agent is asked to execute but has neither a + # text_generation capability nor an LLM client configured + class AgentNotConfiguredError < AgentError + def initialize(message = nil) + super(message || "Agent not configured with LLM capabilities. " \ + "Use DefaultAgentProvider or configure llm_client directly.") + end + end + # Base class for all LLM-related errors class LlmError < StandardError # @return [Hash, nil] The raw response from the LLM API, if available @@ -128,7 +197,7 @@ class LlmAuthenticationError < LlmError # @param response [Hash, nil] The raw response from the LLM API # @param context [Hash, nil] Additional context about the error def initialize(message, response: nil, context: nil) - super(message, response: response, context: context) + super end # @return [Boolean] Whether this error is retryable @@ -143,7 +212,7 @@ class LlmServerError < LlmError # @param response [Hash, nil] The raw response from the LLM API # @param context [Hash, nil] Additional context about the error def initialize(message, response: nil, context: nil) - super(message, response: response, context: context) + super end # @return [Boolean] Whether this error is retryable @@ -157,7 +226,7 @@ class LlmTimeoutError < LlmError # @param message [String] The error message # @param context [Hash, nil] Additional context about the error def initialize(message, context: nil) - super(message, context: context) + super end # @return [Boolean] Whether this error is retryable @@ -172,7 +241,7 @@ class LlmInvalidRequestError < LlmError # @param response [Hash, nil] The raw response from the LLM API # @param context [Hash, nil] Additional context about the error def initialize(message, response: nil, context: nil) - super(message, response: response, context: context) + super end # @return [Boolean] Whether this error is retryable diff --git a/lib/agentic/errors/configuration_error.rb b/lib/agentic/errors/configuration_error.rb deleted file mode 100644 index 52bc8cb..0000000 --- a/lib/agentic/errors/configuration_error.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Agentic - module Errors - # Raised when the library is asked to talk to an LLM without usable - # credentials or endpoint configuration. Raised at client construction - # time so misconfiguration fails at boot, not at request time. - class ConfigurationError < StandardError; end - end -end diff --git a/lib/agentic/errors/validation_error.rb b/lib/agentic/errors/validation_error.rb deleted file mode 100644 index 5acddea..0000000 --- a/lib/agentic/errors/validation_error.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Agentic - module Errors - # Raised when a capability's inputs or outputs violate its declared - # specification. Collects every violation instead of failing on the - # first, so callers can fix a bad payload in one round trip. - class ValidationError < StandardError - # @return [String] The capability whose contract was violated - attr_reader :capability - - # @return [Symbol] Which side of the contract failed (:inputs or :outputs) - attr_reader :kind - - # @return [Hash{Symbol=>Array}] Violation messages keyed by attribute - attr_reader :violations - - # @param capability [String] The capability name - # @param kind [Symbol] :inputs or :outputs - # @param violations [Hash{Symbol=>Array}] Messages keyed by attribute - def initialize(capability:, kind:, violations:) - @capability = capability - @kind = kind - @violations = violations - - details = violations.map { |key, messages| "#{key} #{Array(messages).join(", ")}" }.join("; ") - super("Invalid #{kind} for capability '#{capability}': #{details}") - end - end - end -end diff --git a/lib/agentic/factory_methods.rb b/lib/agentic/factory_methods.rb index 5e9e0a1..13fbfb0 100644 --- a/lib/agentic/factory_methods.rb +++ b/lib/agentic/factory_methods.rb @@ -9,6 +9,14 @@ def self.included(base) end module ClassMethods + # Subclasses inherit their parent's configurable attributes and + # assembly instructions instead of silently starting from nothing + def inherited(subclass) + super + subclass.instance_variable_set(:@configurable_attributes, configurable_attributes.dup) + subclass.instance_variable_set(:@assembly_instructions, assembly_instructions.dup) + end + def build agent = new yield(agent) if block_given? diff --git a/spec/agentic/agent_spec.rb b/spec/agentic/agent_spec.rb index 1f7079b..94b58e2 100644 --- a/spec/agentic/agent_spec.rb +++ b/spec/agentic/agent_spec.rb @@ -27,4 +27,65 @@ expect(agent.tools).to eq(["Custom Tool"]) end end + + describe ".from_h" do + it "builds an agent whose configuration block is actually applied" do + agent = Agentic::Agent.from_h(role: "Restored", purpose: "Round-trip") + + expect(agent).to be_a(Agentic::Agent) + expect(agent.role).to eq("Restored") + expect(agent.purpose).to eq("Round-trip") + end + end + + describe "#execute_with_schema" do + let(:schema) { instance_double(Agentic::StructuredOutputs::Schema) } + + it "honors the schema through the LLM client" do + response = instance_double(Agentic::LlmResponse, successful?: true, content: {"answer" => 42}) + llm_client = instance_double(Agentic::LlmClient) + allow(llm_client).to receive(:complete).with(anything, output_schema: schema).and_return(response) + + agent = Agentic::Agent.build { |a| a.llm_client = llm_client } + + expect(agent.execute_with_schema("What is the answer?", schema)).to eq({"answer" => 42}) + end + + it "refuses to silently drop the schema when only text_generation is available" do + agent = Agentic::Agent.build + agent.instance_variable_get(:@capabilities)["text_generation"] = {specification: nil, provider: nil} + + expect { + agent.execute_with_schema("prompt", schema) + }.to raise_error(Agentic::Errors::SchemaNotSupportedError, /cannot enforce schemas/) + end + + it "raises a named error when the agent has no execution path" do + agent = Agentic::Agent.build + + expect { + agent.execute_with_schema("prompt", schema) + }.to raise_error(Agentic::Errors::AgentNotConfiguredError) + end + end + + describe "capability errors" do + it "raises CapabilityNotFoundError for unregistered capabilities" do + agent = Agentic::Agent.build + + expect { + agent.add_capability("does_not_exist") + }.to raise_error(Agentic::Errors::CapabilityNotFoundError) { |error| + expect(error.capability_name).to eq("does_not_exist") + } + end + + it "raises CapabilityNotFoundError when executing a capability the agent lacks" do + agent = Agentic::Agent.build + + expect { + agent.execute_capability("not_added") + }.to raise_error(Agentic::Errors::CapabilityNotFoundError, /not added to this agent/) + end + end end diff --git a/spec/agentic/factory_methods_spec.rb b/spec/agentic/factory_methods_spec.rb index f19a917..2331ac2 100644 --- a/spec/agentic/factory_methods_spec.rb +++ b/spec/agentic/factory_methods_spec.rb @@ -25,4 +25,29 @@ expect(agent.backstory).to eq("Custom Backstory") end end + + describe "inheritance" do + it "gives subclasses their parent's configurable attributes and assembly" do + subclass = Class.new(Agentic::MockAgent) + + agent = subclass.build do |builder| + builder.role = "Subclassed Role" + end + + expect(agent.role).to eq("Subclassed Role") + expect(agent.goal).to eq("Default Goal") + expect(agent.backstory).to eq("Default Backstory") + end + + it "keeps subclass additions out of the parent" do + subclass = Class.new(Agentic::MockAgent) do + configurable :specialty + end + + agent = subclass.build { |builder| builder.specialty = "Testing" } + + expect(agent.specialty).to eq("Testing") + expect(Agentic::MockAgent.build).not_to respond_to(:specialty) + end + end end From 1fcde5881e633e713158fb89850253e5b9142b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:27:15 +0000 Subject: [PATCH 12/46] feat: real pluggable web_search capability Replace the hardcoded mock web_search implementation with Agentic::Capabilities::WebSearch: a zero-configuration DuckDuckGo Instant Answer backend (no API key, no new dependencies) behind a one-lambda seam for swapping in SerpAPI/Brave/Tavily/internal search. Non-JSON responses (blocked networks, proxy error pages) raise a descriptive Agentic::Error instead of a bare JSON::ParserError. Part of the Rubyist-perspectives build series (Andrew Kane persona); field notes in docs/perspectives/field-notes/10-ankane.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/10-ankane.md | 53 +++++++++++ lib/agentic/capabilities/examples.rb | 21 +---- lib/agentic/capabilities/web_search.rb | 98 ++++++++++++++++++++ spec/agentic/capabilities/web_search_spec.rb | 86 +++++++++++++++++ 4 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 docs/perspectives/field-notes/10-ankane.md create mode 100644 lib/agentic/capabilities/web_search.rb create mode 100644 spec/agentic/capabilities/web_search_spec.rb diff --git a/docs/perspectives/field-notes/10-ankane.md b/docs/perspectives/field-notes/10-ankane.md new file mode 100644 index 0000000..e8e70ea --- /dev/null +++ b/docs/perspectives/field-notes/10-ankane.md @@ -0,0 +1,53 @@ +# Field notes — Andrew Kane (ankane) + +*Build: a real, pluggable `web_search` capability.* + +## What I did + +The README advertises `--capabilities=text_generation,web_search`, and the +registered `web_search` capability returned... `"Result 1 for query: #{q}"` +with `https://example.com/result1` as the source. A demo prop wired into +the default registry, indistinguishable from a real capability until an +agent trusted it in production. + +Now there's `Agentic::Capabilities::WebSearch`: + +- **Works with zero configuration** — the default backend hits DuckDuckGo's + Instant Answer API: no API key, no signup, no new gem dependency + (`Net::HTTP` + `JSON`, both already in the room). My rule for a first-run + experience: `gem install`, one method call, real data. +- **Pluggable in one lambda** — `WebSearch.backend = ->(query:, num_results:) {...}` + swaps in SerpAPI, Brave, Tavily, or your internal index. The backend + contract is the same shape the capability already declared: + `{results: [String], sources: [String]}`. +- The registered standard capability now delegates to the backend, so + `agent.execute_capability("web_search", query: "...")` — and everything + the assembly engine composes on top — gets real results. + +## What I found while doing it + +- The capability's *specification* was already honest (`query` required, + typed outputs) — only the implementation was fake. With solnic's + validator now enforcing contracts, my backend had to return what the spec + promised or fail loudly. That's the ecosystem working: his build + type-checked mine while I wrote it. +- I could not live-verify DuckDuckGo from this sandbox — outbound HTTP is + allowlisted and `api.duckduckgo.com` isn't on the list. The unit tests + inject a fake HTTP client instead, and the raw `JSON::ParserError` you'd + get from a proxy error page is now rescued into an `Agentic::Error` that + says "blocked network? proxy error page?" — because the *first* person to + run this in a locked-down CI should get a sentence, not a stack trace. +- Instant Answers is a real but modest API (abstracts + related topics, not + full SERP). That's the right default tier: free and honest. The lambda + seam is where paid quality plugs in. + +## What I'd ship next (each is a weekend) + +- `agentic-embeddings`: a capability backed by `neighbor` + pgvector for + agent memory; the `PersistentAgentStore` metadata is already begging to + be similarity-searched (the assembly engine literally scores stored + agents against task requirements — with embeddings that's one SQL query). +- `agentic-informers`: local ONNX models as capability providers — zero + API cost for summarization/classification capabilities. +- CI that executes every README snippet. The fake web_search survived + because nothing ran the promises the README made. diff --git a/lib/agentic/capabilities/examples.rb b/lib/agentic/capabilities/examples.rb index 8273290..9e30247 100644 --- a/lib/agentic/capabilities/examples.rb +++ b/lib/agentic/capabilities/examples.rb @@ -102,24 +102,9 @@ def register_web_search provider = CapabilityProvider.new( capability: spec, implementation: lambda do |inputs| - # This is a mock implementation - # In a real implementation, you would use a search API or web scraping - - query = inputs[:query] - num_results = inputs[:num_results] || 3 - - results = num_results.times.map do |i| - "Result #{i + 1} for query: #{query}" - end - - sources = num_results.times.map do |i| - "https://example.com/result#{i + 1}" - end - - { - results: results, - sources: sources - } + # Delegates to the pluggable search backend (DuckDuckGo by + # default; see Capabilities::WebSearch to configure your own) + WebSearch.search(inputs[:query], num_results: inputs[:num_results] || 3) end ) diff --git a/lib/agentic/capabilities/web_search.rb b/lib/agentic/capabilities/web_search.rb new file mode 100644 index 0000000..4387a8b --- /dev/null +++ b/lib/agentic/capabilities/web_search.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "cgi" +require "json" +require "net/http" +require "uri" + +module Agentic + module Capabilities + # Web search with a pluggable backend. + # + # The default backend uses DuckDuckGo's Instant Answer API - no API key, + # no signup - so `web_search` works out of the box. Swap in your own + # backend (SerpAPI, Brave, Tavily, an internal index) with any callable + # that accepts (query:, num_results:) and returns + # {results: [String], sources: [String]}: + # + # Agentic::Capabilities::WebSearch.backend = lambda do |query:, num_results:| + # hits = MySearchClient.search(query, limit: num_results) + # {results: hits.map(&:snippet), sources: hits.map(&:url)} + # end + module WebSearch + class << self + # @return [#call] The active search backend + attr_writer :backend + + # The active search backend (defaults to DuckDuckGo) + # @return [#call] + def backend + @backend ||= DuckDuckGo.new + end + + # Searches the web using the configured backend + # @param query [String] The search query + # @param num_results [Integer] Maximum number of results + # @return [Hash] {results: [String], sources: [String]} + def search(query, num_results: 3) + backend.call(query: query, num_results: num_results) + end + end + + # Zero-configuration backend using DuckDuckGo's Instant Answer API + class DuckDuckGo + ENDPOINT = "https://api.duckduckgo.com/" + + # @param http [#get] HTTP client (defaults to Net::HTTP; injectable for tests) + def initialize(http: Net::HTTP) + @http = http + end + + # @param query [String] The search query + # @param num_results [Integer] Maximum number of results + # @return [Hash] {results: [String], sources: [String]} + def call(query:, num_results: 3) + uri = URI("#{ENDPOINT}?q=#{CGI.escape(query)}&format=json&no_html=1&skip_disambig=1") + body = @http.get(uri).to_s + + begin + data = JSON.parse(body) + rescue JSON::ParserError + raise Agentic::Error, + "Web search backend received a non-JSON response from " \ + "#{ENDPOINT} (blocked network? proxy error page?): #{body[0, 120]}" + end + + entries = extract_entries(data).first(num_results) + + { + results: entries.map { |entry| entry["Text"] }, + sources: entries.map { |entry| entry["FirstURL"] }.compact + } + end + + private + + # Instant Answers nest results under Abstract and RelatedTopics + # (which may themselves contain grouped Topics) + def extract_entries(data) + entries = [] + + if data["AbstractText"] && !data["AbstractText"].empty? + entries << {"Text" => data["AbstractText"], "FirstURL" => data["AbstractURL"]} + end + + Array(data["RelatedTopics"]).each do |topic| + if topic["Topics"] + entries.concat(Array(topic["Topics"]).select { |nested| nested["Text"] }) + elsif topic["Text"] + entries << topic + end + end + + entries + end + end + end + end +end diff --git a/spec/agentic/capabilities/web_search_spec.rb b/spec/agentic/capabilities/web_search_spec.rb new file mode 100644 index 0000000..17d9b70 --- /dev/null +++ b/spec/agentic/capabilities/web_search_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Agentic::Capabilities::WebSearch do + after { Agentic::Capabilities::WebSearch.backend = nil } + + describe ".search" do + it "delegates to the configured backend" do + described_class.backend = lambda do |query:, num_results:| + {results: ["#{query} (#{num_results})"], sources: ["https://internal.example/1"]} + end + + result = described_class.search("ruby agents", num_results: 5) + + expect(result[:results]).to eq(["ruby agents (5)"]) + expect(result[:sources]).to eq(["https://internal.example/1"]) + end + + it "defaults to the DuckDuckGo backend" do + expect(described_class.backend).to be_a(described_class::DuckDuckGo) + end + end + + describe Agentic::Capabilities::WebSearch::DuckDuckGo do + let(:payload) do + { + "AbstractText" => "Ruby is a dynamic language.", + "AbstractURL" => "https://www.ruby-lang.org", + "RelatedTopics" => [ + {"Text" => "Ruby on Rails - a web framework", "FirstURL" => "https://rubyonrails.org"}, + {"Topics" => [ + {"Text" => "Matz - creator of Ruby", "FirstURL" => "https://example.org/matz"} + ]}, + {"Name" => "See also"} + ] + }.to_json + end + + let(:http) { double("http", get: payload) } + + subject(:backend) { described_class.new(http: http) } + + it "flattens abstract and related topics into results with sources" do + result = backend.call(query: "ruby", num_results: 3) + + expect(result[:results]).to eq([ + "Ruby is a dynamic language.", + "Ruby on Rails - a web framework", + "Matz - creator of Ruby" + ]) + expect(result[:sources]).to include("https://www.ruby-lang.org", "https://rubyonrails.org") + end + + it "honors num_results" do + result = backend.call(query: "ruby", num_results: 1) + + expect(result[:results].size).to eq(1) + end + + it "escapes the query" do + backend.call(query: "ruby & rails?", num_results: 1) + + expect(http).to have_received(:get) do |uri| + expect(uri.query).to include("q=ruby+%26+rails%3F") + end + end + end + + describe "standard capability integration" do + it "routes the registered web_search capability through the backend" do + described_class.backend = ->(query:, num_results:) { + {results: ["hit for #{query}"], sources: ["https://example.test"]} + } + + registry = Agentic::AgentCapabilityRegistry.instance + registry.clear + Agentic::Capabilities.register_standard_capabilities + + provider = registry.get_provider("web_search") + result = provider.execute(query: "agentic ruby gem") + + expect(result[:results]).to eq(["hit for agentic ruby gem"]) + end + end +end From b9eff75f5b171ad5784bfb3efd97da30277aa48f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:28:29 +0000 Subject: [PATCH 13/46] fix: Agentic::Logger was silently discarding its level argument Logger#initialize(*args) folded keyword arguments into a positional hash that ::Logger reads as shift_age, so level: was ignored and the logger always ran at DEBUG. Forward with (...) so the :warn default (and any caller-supplied level) takes effect. Addendum to the Jeremy Evans persona build; field notes updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/field-notes/06-jeremyevans.md | 7 +++++++ lib/agentic/logger.rb | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/perspectives/field-notes/06-jeremyevans.md b/docs/perspectives/field-notes/06-jeremyevans.md index 7ce3633..1c2efd2 100644 --- a/docs/perspectives/field-notes/06-jeremyevans.md +++ b/docs/perspectives/field-notes/06-jeremyevans.md @@ -36,6 +36,13 @@ Also worth stating plainly: `filter_sensitive_data` in the spec helper was dutifully scrubbing the string `"ollama"` out of VCR cassettes. Security theater for a credential that never existed. +Postscript: setting the default level to `:warn` did nothing at first. +`Agentic::Logger#initialize(*args)` was folding the `level: :warn` keyword +into a positional hash — which `::Logger` reads as `shift_age` — so every +level ever passed to this constructor had been silently discarded and the +logger always ran at DEBUG. Ruby 3 keyword separation is not optional +trivia. `initialize(...)` forwards correctly; the INFO chatter is gone. + ## What I did not do (yet), and would - The gemspec still ships thor + six tty-* gems + ostruct to every library diff --git a/lib/agentic/logger.rb b/lib/agentic/logger.rb index 8a3bf28..082e4d3 100644 --- a/lib/agentic/logger.rb +++ b/lib/agentic/logger.rb @@ -25,7 +25,10 @@ def call(severity, timestamp, progname, msg) end end - def initialize(*args) + # Forward everything, including keywords: `(*args)` alone would fold + # `level: :warn` into a positional hash that ::Logger reads as shift_age, + # silently discarding the level + def initialize(...) super @formatter = SimpleFormatter.new end From 0e38ee0e3214801c158765399acbddb470538bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:18:24 +0000 Subject: [PATCH 14/46] docs: renga circle example - dependency graphs as poetic form (Matz round 2) Three poet agents compose linked verse under PlanOrchestrator dependencies; field notes record the output-piping and provider frictions hit while building with the gem. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/01-matz.md | 51 +++++++++++++ examples/renga_circle.rb | 105 +++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 docs/perspectives/round-2/01-matz.md create mode 100644 examples/renga_circle.rb diff --git a/docs/perspectives/round-2/01-matz.md b/docs/perspectives/round-2/01-matz.md new file mode 100644 index 0000000..dec668b --- /dev/null +++ b/docs/perspectives/round-2/01-matz.md @@ -0,0 +1,51 @@ +# Round 2 field notes — Matz builds a renga circle + +*Built: `examples/renga_circle.rb` — three poet agents compose a +linked-verse poem where the dependency graph is the poem's form.* + +## What I built and why + +Renga is collaborative poetry with a rule: your verse must answer the one +before it. That is a dependency graph wearing a kimono. So: Basho, Buson, +and Issa as agents, each with a `verse` capability in their own voice, and +a `PlanOrchestrator` whose task dependencies enforce the form — Buson +cannot begin until Basho has spoken. + +``` +first light, autumn wind - (Basho, no dependencies) +answering first: ... (Buson, depends on Basho) +yes, geese - and yet ... (Issa, depends on Buson) +``` + +Eighty milliseconds, `completed`, and a poem. I am content. + +## What building with it felt like + +- Registering a capability per poet and calling + `poet.execute_capability(...)` was pleasant — the lambda-as-craft idea + survives contact with a real (if small) program. +- The orchestrator's dependency declaration is lovely: + `add_task(task, [previous_task.id])` reads exactly like the rule it + encodes. + +## The friction, honestly + +- **Dependent tasks cannot see each other's output.** The whole point of + renga is that verse N reads verse N-1, but a `Task`'s `input` is frozen + at creation and the orchestrator does not pipe a completed task's output + into its dependents. I smuggled a shared `scroll` array into every + agent — mutable shared state, the thing the architecture documents say + they avoid. The framework knows the dependency exists (it scheduled + around it!) yet withholds the one thing the dependency produces. A + `task.input_from(other_task, :verse)` would make this program five lines + shorter and much more honest. +- I also had to invent a `RengaProvider` and a `PoetAtTheTable` adapter + struct because the orchestrator wants a provider-of-agents while I + already *had* my agents. `orchestrator.add_task(task, agent: poet)` + would have let the poets sit at the table directly. + +## Verdict + +The gem let me write a poem with a scheduler, which is the kind of +program Ruby exists for. The missing output-piping between dependent +tasks is the first thing a real user hits — worth building next. diff --git a/examples/renga_circle.rb b/examples/renga_circle.rb new file mode 100644 index 0000000..c9c3700 --- /dev/null +++ b/examples/renga_circle.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# A renga circle: three poet agents compose a linked-verse poem, each +# verse responding to the one before it. The dependency graph IS the +# poem's form - verse 2 cannot begin until verse 1 exists. +# +# bundle exec ruby examples/renga_circle.rb +# +# Runs offline: each poet's craft is a lambda-backed capability. + +require_relative "../lib/agentic" + +# The scroll is passed hand to hand; each poet reads what came before +scroll = [] + +STYLES = { + "Basho" => ->(theme, previous) { + previous ? "#{previous.split.last} lingers -\n#{theme} on the temple bell\na crow shakes off rain" : "first light, #{theme} -\nthe pond remembers\nlast night's moon" + }, + "Buson" => ->(theme, previous) { + "answering #{previous.split.first}:\n#{theme} paints the hillside\nin a brush of geese" + }, + "Issa" => ->(theme, previous) { + "yes, #{previous.split.last} - and yet\neven this #{theme}\nis home to someone small" + } +}.freeze + +# Each poet is an agent with a single "verse" capability +poets = STYLES.to_h do |name, craft| + spec = Agentic::CapabilitySpecification.new( + name: "verse_#{name.downcase}", + description: "Compose a linked verse in #{name}'s voice", + version: "1.0.0", + inputs: { + theme: {type: "string", required: true}, + previous: {type: "string", description: "The verse being answered"} + }, + outputs: {verse: {type: "string", required: true}} + ) + + provider = Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { {verse: craft.call(inputs[:theme], inputs[:previous])} } + ) + Agentic.register_capability(spec, provider) + + poet = Agentic::Agent.build do |a| + a.name = name + a.role = "Renga poet" + end + poet.add_capability("verse_#{name.downcase}") + + [name, poet] +end + +# A poet-agent adapter: when the orchestrator hands it a prompt, it +# reads the scroll, composes, and appends +PoetAtTheTable = Struct.new(:poet, :capability, :theme, :scroll) do + def execute(_prompt) + verse = poet.execute_capability(capability, { + theme: theme, + previous: scroll.last + }.compact)[:verse] + scroll << verse + verse + end +end + +class RengaProvider + def initialize(seats) + @seats = seats + end + + def get_agent_for_task(task) + @seats.fetch(task.description) + end +end + +theme = ARGV.first || "autumn wind" +orchestrator = Agentic::PlanOrchestrator.new + +seats = {} +previous_task = nil +%w[Basho Buson Issa].each do |name| + task = Agentic::Task.new( + description: name, + agent_spec: Agentic::AgentSpecification.new( + name: name, description: "Renga poet", instructions: "Compose one linked verse" + ), + input: {} + ) + seats[name] = PoetAtTheTable.new(poets[name], "verse_#{name.downcase}", theme, scroll) + orchestrator.add_task(task, previous_task ? [previous_task.id] : []) + previous_task = task +end + +result = orchestrator.execute_plan(RengaProvider.new(seats)) + +puts " ~ a renga on \"#{theme}\" ~" +puts +scroll.each_with_index do |verse, i| + puts verse.split("\n").map { |line| " #{line}" } + puts +end +puts " (#{result.status} in #{(result.execution_time * 1000).round}ms)" From 8e1c02dc226b837f27e3ecf8fddd5acd9b9befcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:18:24 +0000 Subject: [PATCH 15/46] docs: HEY-style ticket screener example (DHH round 2) Five tickets screened, categorized, and draft-replied in parallel via capability pipelines; field notes call out the agent-provider ceremony and task-payload workaround as the main consumer friction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/02-dhh.md | 53 ++++++++++++ examples/ticket_screener.rb | 124 ++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 docs/perspectives/round-2/02-dhh.md create mode 100644 examples/ticket_screener.rb diff --git a/docs/perspectives/round-2/02-dhh.md b/docs/perspectives/round-2/02-dhh.md new file mode 100644 index 0000000..d45cb60 --- /dev/null +++ b/docs/perspectives/round-2/02-dhh.md @@ -0,0 +1,53 @@ +# Round 2 field notes — DHH builds a ticket screener + +*Built: `examples/ticket_screener.rb` — a HEY-style screener: every +inbound ticket flows screen → categorize → draft, all tickets in +parallel, and what you get at the end is an inbox.* + +## What I built and why + +The Screener is the best idea in HEY, so that's the demo: five inbound +tickets, two of them junk. Each ticket is a task; the orchestrator fans +all five out in parallel; per ticket, one agent runs three capabilities +in sequence — screen it, categorize it, draft the reply a human will +approve. Output is the screen you'd actually ship: urgent engineering +issue on top with a draft under it, spam below the fold. Five tickets, +43ms, done. + +The three-lambda capability set is the honest version of the pitch: your +*pipeline* is the product, the LLM is an implementation detail. Swap +`draft_reply`'s lambda for the LLM client when you have a key; the inbox +doesn't change shape. + +## What building with it felt like + +- Capabilities-as-lambdas is genuinely good product clay. Three stages, + each declaring its inputs/outputs, each independently swappable — + I built a Screener without a framework diagram. +- The parallel fan-out was free. `concurrency_limit: 5`, add five tasks, + done. This is the part Rails people will not believe is one line. + +## The friction, honestly + +- **The provider ceremony is where my patience went.** I have an agent. + The orchestrator refuses to take my agent; it demands a *provider* that + will be asked to produce an agent per task, so I wrote a `TicketDesk` + struct with a `get_agent_for_task` and a singleton-method worker inside + it. That's thirty lines of adapter for zero domain meaning. Let me pass + a block: `orchestrator.on_task { |task| ... }`. Compress the concept. +- **Task input is dead weight for real work.** The task's `input:` hash + goes into prompt construction, but my worker needed the *ticket*, so I + looked it up by `task.description` like a caveman keying off a string. + Tasks should carry an arbitrary payload the agent can read. +- `Agentic.run` (my round-1 build) was no help here because this workload + is capability-driven, not planner-driven. Fine — but it tells you the + one-liner and the orchestrator live in different products right now. + The compression work isn't finished until they meet. + +## Verdict + +I shipped a Screener in an evening's worth of code, and the framework's +bones — capabilities, parallel tasks, result objects — held. The provider +indirection and the payload workaround are the two paper cuts I'd fix +before showing this to a Rails audience, because they'd ask "why?" twice, +and both times I'd have no answer. diff --git a/examples/ticket_screener.rb b/examples/ticket_screener.rb new file mode 100644 index 0000000..e74854f --- /dev/null +++ b/examples/ticket_screener.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# A HEY-style ticket screener: every inbound support ticket flows +# through screen -> categorize -> draft, all tickets in parallel. +# The inbox you see at the end is the product. +# +# bundle exec ruby examples/ticket_screener.rb +# +# Runs offline: screening heuristics are lambda-backed capabilities. +# With an API key you'd swap the draft capability for the LLM client - +# same pipeline, better prose. + +require_relative "../lib/agentic" + +TICKETS = [ + {id: "T-101", from: "carol@bigco.example", subject: "Invoice shows wrong amount", + body: "Our March invoice is $400 higher than the plan we're on."}, + {id: "T-102", from: "winner@lottery.example", subject: "You have WON $1,000,000!!!", + body: "Click here immediately to claim your prize before it expires!!!"}, + {id: "T-103", from: "dev@startup.example", subject: "API returns 500 on /v2/orders", + body: "Since this morning every POST to /v2/orders returns a 500. Stack trace attached."}, + {id: "T-104", from: "sam@agency.example", subject: "Love the product", + body: "No question - just wanted to say the new dashboard is great."}, + {id: "T-105", from: "urgent-notice@refund-dept.example", subject: "Re: Re: Your account refund", + body: "Dear customer, verify your bank details here to receive your refund."} +].freeze + +# One capability per pipeline stage - small, testable, swappable +def register(name, inputs, outputs, &impl) + spec = Agentic::CapabilitySpecification.new( + name: name, description: name.tr("_", " "), version: "1.0.0", + inputs: inputs, outputs: outputs + ) + Agentic.register_capability( + spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) + ) +end + +register("screen", + {from: {type: "string", required: true}, subject: {type: "string", required: true}, body: {type: "string", required: true}}, + {verdict: {type: "string", required: true}}) do |t| + spammy = t[:subject].count("!") >= 2 || t[:body].match?(/click here|verify your bank|claim your prize/i) + {verdict: spammy ? "screened_out" : "in"} +end + +register("categorize", + {subject: {type: "string", required: true}, body: {type: "string", required: true}}, + {category: {type: "string", required: true}, urgent: {type: "boolean", required: true}}) do |t| + text = "#{t[:subject]} #{t[:body]}".downcase + category = + if text.match?(/invoice|billing|charge|amount/) then "billing" + elsif text.match?(/500|error|bug|stack trace|fail/) then "engineering" + else + "general" + end + {category: category, urgent: text.match?(/500|every|down|urgent/)} +end + +register("draft_reply", + {subject: {type: "string", required: true}, category: {type: "string", required: true}}, + {draft: {type: "string", required: true}}) do |t| + openers = { + "billing" => "Thanks for flagging this - I've pulled up your invoice and I'm checking the discrepancy now.", + "engineering" => "Sorry about that - I've escalated this to the on-call engineer and we're digging in.", + "general" => "Thanks for writing in - really appreciate you taking the time." + } + {draft: "Re: #{t[:subject]}\n #{openers.fetch(t[:category])}"} +end + +# The screener agent owns all three stages +screener = Agentic::Agent.build { |a| a.name = "Screener" } +%w[screen categorize draft_reply].each { |c| screener.add_capability(c) } + +# Each ticket is a task; the orchestrator fans them out in parallel +TicketDesk = Struct.new(:agent, :inbox) do + def get_agent_for_task(task) + desk = self + ticket = TICKETS.find { |t| t[:id] == task.description } + Object.new.tap do |worker| + worker.define_singleton_method(:execute) do |_prompt| + verdict = desk.agent.execute_capability("screen", ticket.slice(:from, :subject, :body))[:verdict] + entry = ticket.slice(:id, :from, :subject).merge(verdict: verdict) + + if verdict == "in" + triage = desk.agent.execute_capability("categorize", ticket.slice(:subject, :body)) + entry[:category] = triage[:category] + entry[:urgent] = triage[:urgent] + entry[:draft] = desk.agent.execute_capability( + "draft_reply", {subject: ticket[:subject], category: triage[:category]} + )[:draft] + end + + desk.inbox << entry + entry + end + end + end +end + +inbox = [] +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 5) +TICKETS.each do |ticket| + orchestrator.add_task(Agentic::Task.new( + description: ticket[:id], + agent_spec: {"name" => "Screener", "instructions" => "Screen this ticket"}, + input: {} + )) +end +result = orchestrator.execute_plan(TicketDesk.new(screener, inbox)) + +screened_in = inbox.select { |t| t[:verdict] == "in" }.sort_by { |t| t[:urgent] ? 0 : 1 } +screened_out = inbox - screened_in + +puts "INBOX (#{screened_in.size})" +screened_in.each do |t| + flag = t[:urgent] ? "URGENT " : "" + puts " #{flag}[#{t[:category]}] #{t[:id]} #{t[:subject]} - #{t[:from]}" + puts " #{t[:draft]}" +end +puts +puts "SCREENED OUT (#{screened_out.size})" +screened_out.each { |t| puts " #{t[:id]} #{t[:subject]}" } +puts +puts "(#{TICKETS.size} tickets, #{result.status} in #{(result.execution_time * 1000).round}ms)" From f502e3f4f3f44c9260675e3e3345bd39151cd5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:20:40 +0000 Subject: [PATCH 16/46] docs: Prism-powered performance detective example (tenderlove round 2) One orchestrator task per lib/ file, each dissected with Prism; the gem audits its own method lengths. Field notes cover the regex-vs-parser lesson and why fiber concurrency doesn't speed up CPU-bound tasks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/03-tenderlove.md | 54 +++++++++++ examples/performance_detective.rb | 101 +++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/perspectives/round-2/03-tenderlove.md create mode 100644 examples/performance_detective.rb diff --git a/docs/perspectives/round-2/03-tenderlove.md b/docs/perspectives/round-2/03-tenderlove.md new file mode 100644 index 0000000..f2497d0 --- /dev/null +++ b/docs/perspectives/round-2/03-tenderlove.md @@ -0,0 +1,54 @@ +# Round 2 field notes — Aaron Patterson builds the Performance Detective + +*Built: `examples/performance_detective.rb` — one orchestrator task per +Ruby file in `lib/`, each dissecting the file with Prism. The gem +investigates itself. The report names names.* + +## What I built and why + +63 files, one task each, fanned through the `PlanOrchestrator`; a +`dissect_file` capability parses each file with **Prism** (Ruby's own +parser, stdlib since 3.3) and measures every `def`. The output is a case +file: the seven longest methods in the gem and the densest files. + +The usual suspects, for the record: `generate_optimized_sequence` at 110 +lines, `schedule_task` at 90, `adjust_plan_via_llm` at 87. Sandi, your +victims are pre-selected; you're welcome. + +## The confession + +My first draft hand-rolled the method finder with regexes and an +`end`-counting stack. It reported a 353-line `store` method — because a +line reading `end,` (block as hash value) isn't `end`, so my stack never +popped and everything after got charged to `store`. Also three files blew +up with `invalid byte sequence in US-ASCII` because someone put a 🤖 in +`execution_observer.rb` and my `File.foreach` trusted `LANG`. Both bugs +vanished the moment I used the actual grammar: `Prism.parse_file` gives +you `DefNode#location.start_line/end_line` and handles encoding like a +parser should. The lesson never changes: **stop parsing Ruby with +regexes. We shipped you a parser. It's right there.** + +## The measurement that matters + +Concurrency 16: 96ms. Concurrency 1: 118ms. Nearly nothing — and that's +the honest, load-bearing observation for this framework: the orchestrator +runs tasks as **fibers under async**, which is cooperative concurrency +for *IO*. Parsing is CPU-bound, fibers don't parallelize CPU, so sixteen +lanes of traffic still share one engine. When your tasks are LLM calls +(network IO), this same fan-out is a massive win; when they're compute, +it's a progress bar. Frameworks should say this out loud in their docs — +users will assume `concurrency_limit: 16` means 16× everything. + +## Friction while building + +Same two walls Matz and David hit, so I'll just +1 them: I keyed the file +path through `task.description` because tasks carry no payload, and I +built a `Casefile` provider + singleton-method worker because the +orchestrator won't take a callable. The adapter tax is real: ~20 of my +~110 lines are plumbing that says nothing about detection or files. + +## Verdict + +Prism-powered self-audit through the gem's own scheduler, in about a +hundred lines. The fan-out API is genuinely pleasant once the adapter is +paid for — and the case file gave the whole team a refactoring hit list. diff --git a/examples/performance_detective.rb b/examples/performance_detective.rb new file mode 100644 index 0000000..f19b33c --- /dev/null +++ b/examples/performance_detective.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# The Performance Detective: one task per Ruby file in lib/, fanned out +# through the orchestrator, each dissecting a file for long methods. +# The victim is this very gem. The report names names. +# +# bundle exec ruby examples/performance_detective.rb [concurrency] +# +# Runs offline - the "agent" here is Prism, Ruby's own parser, because +# the best LLM for counting your method lengths is the actual grammar. + +require_relative "../lib/agentic" +require "prism" + +LIB = File.expand_path("../lib", __dir__) + +# Walks a parsed tree collecting every def with its measured length +def collect_defs(node, found) + return unless node + + if node.is_a?(Prism::DefNode) + found << { + name: node.receiver ? "self.#{node.name}" : node.name.to_s, + line: node.location.start_line, + lines: node.location.end_line - node.location.start_line + 1 + } + end + node.child_nodes.each { |child| collect_defs(child, found) } +end + +# A capability that dissects one file: every def, with its length +spec = Agentic::CapabilitySpecification.new( + name: "dissect_file", + description: "Measure the methods in one Ruby file", + version: "1.0.0", + inputs: {path: {type: "string", required: true}}, + outputs: {methods: {type: "array", required: true}, lines: {type: "number", required: true}} +) +provider = Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { + parsed = Prism.parse_file(inputs[:path]) + methods = [] + collect_defs(parsed.value, methods) + + {methods: methods, lines: parsed.source.source.count("\n")} + } +) +Agentic.register_capability(spec, provider) + +detective = Agentic::Agent.build { |a| a.name = "Detective" } +detective.add_capability("dissect_file") + +# Every file is a lead; every lead gets a task +Casefile = Struct.new(:agent, :evidence) do + def get_agent_for_task(task) + casefile = self + Object.new.tap do |gumshoe| + gumshoe.define_singleton_method(:execute) do |_prompt| + report = casefile.agent.execute_capability("dissect_file", {path: task.description}) + casefile.evidence[task.description] = report + "#{report[:methods].size} methods" + end + end + end +end + +concurrency = (ARGV.first || 16).to_i +files = Dir[File.join(LIB, "**", "*.rb")].sort + +evidence = {} +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency) +files.each do |path| + orchestrator.add_task(Agentic::Task.new( + description: path, + agent_spec: {"name" => "Detective", "instructions" => "Dissect the file"}, + input: {} + )) +end + +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +result = orchestrator.execute_plan(Casefile.new(detective, evidence)) +elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round + +all_methods = evidence.flat_map do |path, report| + report[:methods].map { |m| m.merge(file: path.delete_prefix("#{LIB}/")) } +end + +puts "CASE FILE: #{files.size} files, #{all_methods.size} methods, " \ + "#{evidence.values.sum { |r| r[:lines] }} lines" +puts "(#{result.status} in #{elapsed_ms}ms at concurrency #{concurrency})" +puts +puts "THE USUAL SUSPECTS (longest methods):" +all_methods.sort_by { |m| -m[:lines] }.first(7).each do |m| + puts format(" %3d lines %s (%s:%d)", m[:lines], m[:name], m[:file], m[:line]) +end +puts +puts "DENSEST NEIGHBORHOODS (methods per file):" +evidence.sort_by { |_, r| -r[:methods].size }.first(5).each do |path, r| + puts format(" %3d methods %s", r[:methods].size, path.delete_prefix("#{LIB}/")) +end From 796a0d81dca5d31f4cc5ec8d0150fcdb6bacfd86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:23:52 +0000 Subject: [PATCH 17/46] docs: namespace cartographer example (fxn round 2) Maps a gem's constant tree via per-file survey tasks and Prism, auditing each file against the constant Zeitwerk expects - including the GemInflector version.rb special case the map itself initially got wrong. Field notes on verifying the verifier. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/04-fxn.md | 60 ++++++++++++++ examples/namespace_cartographer.rb | 122 ++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 docs/perspectives/round-2/04-fxn.md create mode 100644 examples/namespace_cartographer.rb diff --git a/docs/perspectives/round-2/04-fxn.md b/docs/perspectives/round-2/04-fxn.md new file mode 100644 index 0000000..9a8a244 --- /dev/null +++ b/docs/perspectives/round-2/04-fxn.md @@ -0,0 +1,60 @@ +# Round 2 field notes — Xavier Noria builds the Namespace Cartographer + +*Built: `examples/namespace_cartographer.rb` — one orchestrator task per +file, Prism reading actual definitions, producing a map of a gem's +constant tree and auditing every file against the constant its path +promises.* + +## What I built and why + +A cartographer, because the loader conventions are a *projection* between +two spaces — file paths and constant paths — and any projection deserves +a map. Point it at a `lib/` directory and it fans one survey task per +file through the `PlanOrchestrator`; each survey parses the file with +Prism and records every module, class, and constant it defines. Then the +map is compared with the territory: 63 files, and the verdict for this +gem after round one's cleanup is the sentence I hoped to print: + +> Every file defines the constant its path promises. The map IS the +> territory. + +## The best moment: my map was wrong first + +The first run reported one deviation: `agentic/version.rb`, "expected +`Agentic::Version`, defines `Agentic`". I nearly filed it as a finding — +then remembered whose rule this is. `Zeitwerk::Loader.for_gem` uses +`GemInflector`, which **special-cases the gem's `version.rb` to expect +`VERSION`**, precisely so the classic `Foo::VERSION` constant conforms. +My cartographer's inflector didn't know the special case, so the +deviation was in the *map*, not the territory. I taught the map the rule +and the deviation disappeared. + +I want to underline this because it is the whole discipline in +miniature: a conformance tool is itself a model of the convention, and a +model can be wrong in exactly the ways it accuses others of. Verify the +verifier. (It took `const_source_location` and a read of Zeitwerk's +`cref.rb` to be sure which side was mistaken.) + +## Building-with-it observations + +- The fan-out was the right shape for a survey: files are independent, + order is irrelevant, and the orchestrator's result object gave me + status and timing for free. 110ms for 63 files. +- Same adapter tax my colleagues reported: an `Expedition` provider + struct and a path smuggled through `task.description`. I now believe + this is the framework's single most instructive piece of user + feedback: four builders, four identical workarounds, one missing + affordance — tasks need a payload and the orchestrator should accept + agents (or callables) directly. +- Capability declarations (`inputs: {path: ...}` → `outputs: {defined: + ...}`) made the survey's contract explicit, and solnic's validator + enforced it while I iterated. Typed seams between stages are worth + their ceremony when a stage is being rewritten — which, see above, it + was. + +## Verdict + +The gem is a competent expedition outfitter: it carried Prism up the +mountain and back without complaint. And the exercise produced a +sentence every Zeitwerk user should frame: the map is not the territory +— except when your naming conventions hold, and then, wonderfully, it is. diff --git a/examples/namespace_cartographer.rb b/examples/namespace_cartographer.rb new file mode 100644 index 0000000..2b55c1f --- /dev/null +++ b/examples/namespace_cartographer.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# The Namespace Cartographer: maps a gem's constant tree and audits +# every file against the constant Zeitwerk expects it to define. +# One orchestrator task per file; Prism reads the actual definitions. +# +# bundle exec ruby examples/namespace_cartographer.rb [lib_dir] +# +# Defaults to surveying this gem. A conforming codebase produces a map +# with no annotations; every deviation is listed with what was expected +# and what was found. + +require_relative "../lib/agentic" +require "prism" + +LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") +INFLECTIONS = {"cli" => "CLI", "ui" => "UI"}.freeze + +def camelize(segment) + INFLECTIONS.fetch(segment) { segment.split("_").map(&:capitalize).join } +end + +# The constant Zeitwerk expects lib/foo/bar_baz.rb to define. +# Zeitwerk::GemInflector special-cases the gem's version.rb: it expects +# Foo::VERSION, not Foo::Version - a lesson this cartographer learned +# by first drawing the deviation on its own map. +def expected_constant(relative_path) + segments = relative_path.delete_suffix(".rb").split("/") + return "#{camelize(segments.first)}::VERSION" if segments.length == 2 && segments.last == "version" + + segments.map { |seg| camelize(seg) }.join("::") +end + +# Collects every module/class defined in a parse tree, as full paths +def collect_definitions(node, namespace, found) + return unless node + + case node + when Prism::ModuleNode, Prism::ClassNode + name = node.constant_path.slice + full = [namespace, name].reject(&:empty?).join("::") + found << full + node.child_nodes.each { |child| collect_definitions(child, full, found) } + when Prism::ConstantWriteNode + found << [namespace, node.name.to_s].reject(&:empty?).join("::") + else + node.child_nodes.each { |child| collect_definitions(child, namespace, found) } + end +end + +spec = Agentic::CapabilitySpecification.new( + name: "survey_file", + description: "Chart the constants a Ruby file defines", + version: "1.0.0", + inputs: {path: {type: "string", required: true}}, + outputs: {defined: {type: "array", required: true}} +) +Agentic.register_capability(spec, Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { + found = [] + collect_definitions(Prism.parse_file(inputs[:path]).value, "", found) + {defined: found} + } +)) + +surveyor = Agentic::Agent.build { |a| a.name = "Cartographer" } +surveyor.add_capability("survey_file") + +Expedition = Struct.new(:agent, :charts) do + def get_agent_for_task(task) + expedition = self + Object.new.tap do |scout| + scout.define_singleton_method(:execute) do |_prompt| + expedition.charts[task.description] = + expedition.agent.execute_capability("survey_file", {path: task.description})[:defined] + "charted" + end + end + end +end + +files = Dir[File.join(LIB, "**", "*.rb")].sort +charts = {} +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) +files.each do |path| + orchestrator.add_task(Agentic::Task.new( + description: path, + agent_spec: {"name" => "Cartographer", "instructions" => "Survey the file"}, + input: {} + )) +end +result = orchestrator.execute_plan(Expedition.new(surveyor, charts)) + +# Compare the map against the territory +deviations = [] +tree = Hash.new(0) +files.each do |path| + relative = path.delete_prefix("#{LIB}/") + expected = expected_constant(relative) + defined = charts.fetch(path, []) + + tree[relative.split("/").first(2).join("/").delete_suffix(".rb")] += 1 + unless defined.include?(expected) + deviations << {file: relative, expected: expected, found: defined.first(3)} + end +end + +puts "NAMESPACE MAP of #{LIB}" +puts "(#{files.size} files surveyed, #{result.status} in #{(result.execution_time * 1000).round}ms)" +puts +tree.sort.each { |region, count| puts format(" %-46s %3d file(s)", region, count) } +puts +if deviations.empty? + puts "Every file defines the constant its path promises. The map IS the territory." +else + puts "DEVIATIONS (#{deviations.size}):" + deviations.each do |d| + puts " #{d[:file]}" + puts " expected #{d[:expected]}, defines #{d[:found].join(", ")}" + end +end From 29bd4451a5ee19ffe7b16c598bd712d918ce8a8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:25:07 +0000 Subject: [PATCH 18/46] docs: latency lab example (ioquatix round 2) Measures orchestrator fan-out at concurrency 1/4/20 against ideal (within 10ms at every limit) and proves a plan shares a host reactor with sibling tasks instead of monopolizing it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/05-ioquatix.md | 58 +++++++++++++++++ examples/latency_lab.rb | 82 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/perspectives/round-2/05-ioquatix.md create mode 100644 examples/latency_lab.rb diff --git a/docs/perspectives/round-2/05-ioquatix.md b/docs/perspectives/round-2/05-ioquatix.md new file mode 100644 index 0000000..ca40b39 --- /dev/null +++ b/docs/perspectives/round-2/05-ioquatix.md @@ -0,0 +1,58 @@ +# Round 2 field notes — Samuel Williams builds the Latency Lab + +*Built: `examples/latency_lab.rb` — 20 simulated LLM calls through the +orchestrator at three concurrency limits, plus a heartbeat sharing the +reactor to prove the plan composes instead of monopolizing.* + +## What I built and why + +Aaron's detective showed fibers buy nothing for CPU-bound work; this lab +shows what they buy for the workload this gem actually exists for. Twenty +tasks, each 200ms of simulated IO (`sleep`, which under the fiber +scheduler yields exactly like a socket read). Measured on this machine: + +``` +concurrency 1 -> 4.01s wall (ideal 4.00s) +concurrency 4 -> 1.00s wall (ideal 1.00s) +concurrency 20 -> 0.20s wall (ideal 0.20s) +``` + +Within 10ms of theoretical at every limit. That's the semaphore doing +precisely its job: at limit 20, twenty "API calls" cost one API call of +wall clock. This is the number to show anyone who asks why an agent +framework should care about structured concurrency — a 20-task LLM plan +is 4 seconds of latency serial and 0.2 seconds fanned out, and the code +difference is one integer. + +## The composition proof + +The second half runs the plan **inside** a host reactor, alongside a +heartbeat task beating every 100ms. The heartbeat kept beating (4 beats +during a 0.4s plan) — the orchestrator joined the reactor as a sibling +rather than seizing the event loop. This is the behavior my round-1 `Sync` +change bought, now demonstrated from the consumer side: you can embed a +plan in a Falcon request handler, next to your websocket pings, and +nobody starves. Before that change, this program would have printed the +starvation line. + +## Building-with-it observations + +- `sleep` being non-blocking inside tasks is delightful and *undocumented*. + Users writing custom agents need to know: any Ruby IO — `Net::HTTP`, + `sleep`, sockets — cooperates automatically under the reactor, and + anything that grabs the GVL for compute does not. One paragraph in the + README would set expectations for both. +- `PlanExecutionResult#execution_time` made the lab's measurement code + trivial — the framework timing its own plans is a small design decision + that keeps paying off. +- The one rough edge: `concurrency_limit` is per-orchestrator, but the + thing you actually want to bound is usually per-*provider* (one OpenAI + key = one rate limit shared across every plan in the process). A shared + semaphore injected into the client would express that. Noted for + round 3. + +## Verdict + +The concurrency story survives contact with measurement: ideal scaling +on IO, honest nothing on CPU, and polite cohabitation inside a host +reactor. That's the whole async contract, kept. diff --git a/examples/latency_lab.rb b/examples/latency_lab.rb new file mode 100644 index 0000000..8af0637 --- /dev/null +++ b/examples/latency_lab.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# The Latency Lab: 20 simulated LLM calls (200ms of IO each) executed +# through the orchestrator at different concurrency limits, all inside +# ONE reactor - plus a heartbeat task running alongside the plan to +# prove the orchestrator composes with its host instead of seizing it. +# +# bundle exec ruby examples/latency_lab.rb +# +# Runs offline: the "API" is sleep, which under the async fiber +# scheduler yields exactly like a socket would. + +require_relative "../lib/agentic" + +TASK_COUNT = 20 +SIMULATED_LATENCY = 0.2 # seconds per "API call" + +# An agent whose only skill is waiting on the network, convincingly +class SimulatedApiAgent + def execute(_prompt) + sleep(SIMULATED_LATENCY) # non-blocking under the fiber scheduler + {"status" => "ok"} + end +end + +class LabProvider + def get_agent_for_task(_task) + SimulatedApiAgent.new + end +end + +def build_orchestrator(limit) + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: limit) + TASK_COUNT.times do |i| + orchestrator.add_task(Agentic::Task.new( + description: "call ##{i + 1}", + agent_spec: {"name" => "API", "instructions" => "wait"}, + input: {} + )) + end + orchestrator +end + +puts "#{TASK_COUNT} tasks x #{(SIMULATED_LATENCY * 1000).round}ms of simulated IO" +puts "serial floor: #{(TASK_COUNT * SIMULATED_LATENCY).round(1)}s | " \ + "perfect fan-out: #{SIMULATED_LATENCY}s" +puts + +[1, 4, 20].each do |limit| + result = build_orchestrator(limit).execute_plan(LabProvider.new) + ideal = (TASK_COUNT.to_f / limit) * SIMULATED_LATENCY + puts format( + "concurrency %2d -> %5.2fs wall (ideal %5.2fs, %d/%d completed)", + limit, result.execution_time, ideal, + result.results.count { |_, r| r.successful? }, TASK_COUNT + ) +end + +# Composition proof: the plan runs INSIDE a host reactor while a +# sibling heartbeat task keeps beating - the orchestrator joins the +# reactor rather than blocking it +puts +puts "composition check (plan + heartbeat sharing one reactor):" +beats = 0 +Sync do |host| + heartbeat = host.async do + loop do + sleep(0.1) + beats += 1 + end + end + + result = build_orchestrator(10).execute_plan(LabProvider.new) + heartbeat.stop + + puts format( + " plan: %.2fs, heartbeat kept beating: %d beats while the plan ran", + result.execution_time, beats + ) +end +puts beats.positive? ? " the reactor stayed alive - structured concurrency, not a hijack" : + " heartbeat starved - the orchestrator monopolized the reactor!" From ba73c552f5678e3081976e14c3114aa61023cc57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:26:17 +0000 Subject: [PATCH 19/46] docs: schema advisor example (Jeremy Evans round 2) Four deterministic DBA rules as capabilities, one review task per table; advisories with exact remediation. Field notes argue facts belong to rules and prose to models. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/06-jeremyevans.md | 50 +++++++ examples/schema_advisor.rb | 151 ++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/perspectives/round-2/06-jeremyevans.md create mode 100644 examples/schema_advisor.rb diff --git a/docs/perspectives/round-2/06-jeremyevans.md b/docs/perspectives/round-2/06-jeremyevans.md new file mode 100644 index 0000000..75d7076 --- /dev/null +++ b/docs/perspectives/round-2/06-jeremyevans.md @@ -0,0 +1,50 @@ +# Round 2 field notes — Jeremy Evans builds the Schema Advisor + +*Built: `examples/schema_advisor.rb` — four deterministic DBA rules as +capabilities, one review task per table, advisories sorted by severity.* + +## What I built and why + +A schema review: feed it table definitions and a query log, get back the +advisories a careful DBA writes on every consulting gig — queries +filtering on unindexed columns (with the exact `add_index` to run), money +stored in floats, NULL-permissive columns, text primary keys. Three +tables and four logged queries produced thirteen advisories, every one of +them the kind of thing that pages you at 3 a.m. two years from now. + +The deliberate design decision: **the rules are deterministic lambdas, +not LLM prompts.** "You filter on `orders.user_id` and have no index on +it" is a *fact*, computable from the schema and the log, and a fact +should never be outsourced to a probabilistic system that might phrase it +differently on Tuesdays. The place an LLM would earn its keep in this +program is the one seat I left open: prose-summarizing the advisory list +for a human audience. Facts from rules, prose from models — that division +of labor is the correct architecture for every "AI code review" product I +have seen, and most of them get it backwards. + +## Building-with-it observations + +- Capabilities were the right container for rules: each declares its + inputs (`table`, `definition`, `queries`) and its output shape, so + adding rule five is registering one lambda. The registry gives you a + rule engine without writing a rule engine. +- solnic's validator earned its keep again: my first `check_money_types` + returned `advice:` strings under the wrong key and got an immediate + `ValidationError` naming the violation, instead of an empty report and + twenty minutes of puzzlement. Strict boundaries between stages are + cheap insurance exactly when you're writing many small stages. +- Per-table fan-out through the orchestrator is honest scaling: with 400 + tables instead of 3, `concurrency_limit: 4` becomes meaningful and the + program doesn't change. Correct programs should scale by changing + constants, not shape. +- The now-canonical gripes, confirmed independently once more: I keyed + the table through `task.description`, and wrote a `Consultation` + provider adapter. Five personas, five identical adapters. The evidence + phase is over; the API should accept the verdict. + +## Verdict + +A rule engine with typed seams and free parallelism, in one file, no new +dependencies. Wire it to a real `Sequel::Database#schema` and a +`pg_stat_statements` dump and this stops being an example — which is the +test an example should pass. diff --git a/examples/schema_advisor.rb b/examples/schema_advisor.rb new file mode 100644 index 0000000..64b9cb9 --- /dev/null +++ b/examples/schema_advisor.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# The Schema Advisor: give it a schema and a query log, get back the +# advisories a careful DBA would write - each rule its own capability, +# each table analyzed as its own task. +# +# bundle exec ruby examples/schema_advisor.rb +# +# Runs offline: the rules are deterministic. Rules that fire are facts +# about your schema, not opinions from a model. + +require_relative "../lib/agentic" + +SCHEMA = { + "users" => { + columns: { + "id" => {type: "integer", primary_key: true}, + "email" => {type: "text", null: true}, + "created_at" => {type: "timestamp", null: true} + }, + indexes: ["id"] + }, + "orders" => { + columns: { + "id" => {type: "integer", primary_key: true}, + "user_id" => {type: "integer", null: true}, + "status" => {type: "text", null: true}, + "total_cents" => {type: "float", null: true} + }, + indexes: ["id"] + }, + "audit_logs" => { + columns: { + "uuid" => {type: "text", primary_key: true}, + "payload" => {type: "text", null: true}, + "user_id" => {type: "integer", null: true} + }, + indexes: [] + } +}.freeze + +QUERY_LOG = [ + "SELECT * FROM orders WHERE user_id = ?", + "SELECT * FROM orders WHERE status = ? ORDER BY id DESC", + "SELECT * FROM users WHERE email = ?", + "SELECT * FROM audit_logs WHERE user_id = ?" +].freeze + +def register_rule(name, &impl) + spec = Agentic::CapabilitySpecification.new( + name: name, description: name.tr("_", " "), version: "1.0.0", + inputs: { + table: {type: "string", required: true}, + definition: {type: "object", required: true}, + queries: {type: "array", required: true} + }, + outputs: {advisories: {type: "array", required: true}} + ) + Agentic.register_capability( + spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) + ) +end + +register_rule("check_missing_indexes") do |input| + table, definition, queries = input.values_at(:table, :definition, :queries) + filtered = queries.filter_map { |q| q[/FROM #{table} WHERE (\w+)/, 1] }.uniq + advisories = (filtered - definition[:indexes]).map do |column| + {severity: "high", table: table, + advice: "queries filter on #{table}.#{column} but no index covers it - add_index :#{table}, :#{column}"} + end + {advisories: advisories} +end + +register_rule("check_null_discipline") do |input| + table, definition = input.values_at(:table, :definition) + advisories = definition[:columns].filter_map do |column, meta| + next if meta[:primary_key] || meta[:null] == false + + {severity: "medium", table: table, + advice: "#{table}.#{column} allows NULL - if it's required, say so: NOT NULL with a default beats a validation"} + end + {advisories: advisories} +end + +register_rule("check_money_types") do |input| + table, definition = input.values_at(:table, :definition) + advisories = definition[:columns].filter_map do |column, meta| + next unless column.match?(/cents|price|amount|total/) && meta[:type] == "float" + + {severity: "high", table: table, + advice: "#{table}.#{column} stores money as float - use integer cents or decimal before rounding errors become refunds"} + end + {advisories: advisories} +end + +register_rule("check_text_primary_keys") do |input| + table, definition = input.values_at(:table, :definition) + advisories = definition[:columns].filter_map do |column, meta| + next unless meta[:primary_key] && meta[:type] == "text" + + {severity: "low", table: table, + advice: "#{table}.#{column} is a text primary key - fine if it's truly a UUID column type, expensive if it's a string"} + end + {advisories: advisories} +end + +RULES = %w[check_missing_indexes check_null_discipline check_money_types check_text_primary_keys].freeze + +dba = Agentic::Agent.build { |a| a.name = "DBA" } +RULES.each { |rule| dba.add_capability(rule) } + +Consultation = Struct.new(:agent, :findings) do + def get_agent_for_task(task) + consultation = self + Object.new.tap do |reviewer| + reviewer.define_singleton_method(:execute) do |_prompt| + table = task.description + RULES.each do |rule| + result = consultation.agent.execute_capability(rule, { + table: table, definition: SCHEMA[table], queries: QUERY_LOG + }) + consultation.findings.concat(result[:advisories]) + end + "reviewed" + end + end + end +end + +findings = [] +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) +SCHEMA.each_key do |table| + orchestrator.add_task(Agentic::Task.new( + description: table, + agent_spec: {"name" => "DBA", "instructions" => "Review the table"}, + input: {} + )) +end +result = orchestrator.execute_plan(Consultation.new(dba, findings)) + +puts "SCHEMA REVIEW: #{SCHEMA.size} tables, #{QUERY_LOG.size} logged queries, " \ + "#{findings.size} advisories (#{result.status})" +puts +%w[high medium low].each do |severity| + matching = findings.select { |f| f[:severity] == severity } + next if matching.empty? + + puts severity.upcase + matching.each { |f| puts " - #{f[:advice]}" } + puts +end From 7e4d2265ad1e23e6c74ddfdca4e29fb3374bc74c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:27:29 +0000 Subject: [PATCH 20/46] docs: typed ETL pipeline example (solnic round 2) Extract/transform/load capabilities composed via registry.compose; malformed records are rejected at the first contract boundary with every violation named. Field notes on parse-optimistically, validate-at-the-boundary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/07-solnic.md | 64 +++++++++++++++ examples/typed_pipeline.rb | 107 +++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/perspectives/round-2/07-solnic.md create mode 100644 examples/typed_pipeline.rb diff --git a/docs/perspectives/round-2/07-solnic.md b/docs/perspectives/round-2/07-solnic.md new file mode 100644 index 0000000..7a68fbe --- /dev/null +++ b/docs/perspectives/round-2/07-solnic.md @@ -0,0 +1,64 @@ +# Round 2 field notes — Piotr Solnica builds a typed ETL pipeline + +*Built: `examples/typed_pipeline.rb` — extract → transform → load as +contract-bearing capabilities composed via `registry.compose`; malformed +data is stopped at the first boundary that can name what's wrong.* + +## What I built and why + +Four raw payment events, one of them garbage (`user=` empty, +`amount_cents=not-a-number`). Three capabilities with deliberately +different strictness: **extract** is forgiving (parsing is not +judgment), **transform** is where loose fields must become facts (its +*output* contract requires a present user and numeric amount), **load** +trusts its input contract completely. `registry.compose` fuses them into +one `etl_pipeline` capability. + +The run prints the thesis better than I can: + +``` +POSTED ev-1 +REJECTED ev-3 at the 'transform' outputs boundary: + user: is missing + amount_cents: must be Numeric +LEDGER (only facts made it this far): + USD 1041.00 +``` + +Both violations, named, at the boundary that first noticed — and the +ledger arithmetic never saw the poison. That is the entire dry-rb +philosophy in fourteen lines of output. + +## The design move worth stealing + +The transform lambda doesn't validate. It *parses optimistically* and +lets its own **output contract** catch what didn't parse — `Integer()` +falls back to the raw string, empty user becomes an omitted key, and +the declared schema (built in round 1 on the gem's own dry-schema +dependency) does the rejecting with structured violations. Stages stay +dumb; boundaries stay strict. When validation logic lives in the +contract instead of the stage, adding stage four costs nothing and the +error messages stay uniform across the pipeline. + +## Building-with-it observations + +- `registry.compose` is a genuinely nice primitive — providers arrive as + an ordered array and the composition lambda is just function + composition. But the composed capability's own `inputs`/`outputs` are + **not declarable** (the `compose` signature accepts no contracts for + the whole), so the pipeline-as-a-unit has no contract even though + every stage does. The seam between compositions is exactly where + you want types most. +- One `ValidationError` carrying `capability`, `kind`, and all + violations made the rescue-and-report loop four lines. Errors designed + as data compose into UIs; errors designed as prose compose into grep. +- No orchestrator here, deliberately: per-record sequential flow with a + contract at each seam didn't need one. Right-sized tools — the + registry alone is a respectable pipeline runtime. + +## Verdict + +The gem let me express "data becomes facts at a named boundary" without +importing anything beyond what it already shipped. Give composed +capabilities their own contracts and this pattern would be +production-honest end to end. diff --git a/examples/typed_pipeline.rb b/examples/typed_pipeline.rb new file mode 100644 index 0000000..8d2fc81 --- /dev/null +++ b/examples/typed_pipeline.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# A typed ETL pipeline: extract -> transform -> load, each stage a +# capability with a declared contract, composed into one capability via +# the registry. Bad data doesn't flow downstream - it's stopped at the +# boundary that first notices, with every violation named. +# +# bundle exec ruby examples/typed_pipeline.rb +# +# Runs offline. The interesting output is the FAILED record: watch +# where it stops and what the error says. + +require_relative "../lib/agentic" + +registry = Agentic::AgentCapabilityRegistry.instance + +RAW_EVENTS = [ + %(id=ev-1|user=ada@example.com|amount_cents=4200|currency=USD), + %(id=ev-2|user=grace@example.com|amount_cents=1850|currency=EUR), + %(id=ev-3|user=|amount_cents=not-a-number|currency=USD), + %(id=ev-4|user=joan@example.com|amount_cents=99900|currency=USD) +].freeze + +def capability(name, inputs:, outputs:, &impl) + spec = Agentic::CapabilitySpecification.new( + name: name, description: name, version: "1.0.0", + inputs: inputs, outputs: outputs + ) + Agentic.register_capability( + spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) + ) +end + +# Extract: raw line in, loosely-typed fields out. Extraction is +# forgiving - its job is parsing, not judgment. +capability("extract", + inputs: {raw: {type: "string", required: true}}, + outputs: {fields: {type: "object", required: true}}) do |input| + fields = input[:raw].split("|").to_h { |pair| pair.split("=", 2) } + {fields: fields} +end + +# Transform: loose fields in, STRICT record out. This is the boundary +# where "data" becomes "facts" - the contract insists amount is a +# number and user is present. +capability("transform", + inputs: {fields: {type: "object", required: true}}, + outputs: { + id: {type: "string", required: true}, + user: {type: "string", required: true}, + amount_cents: {type: "number", required: true}, + currency: {type: "string", required: true} + }) do |input| + fields = input[:fields] + amount = begin + Integer(fields["amount_cents"]) + rescue ArgumentError, TypeError + fields["amount_cents"] # let the output contract catch it, by name + end + user = fields["user"].to_s.empty? ? nil : fields["user"] + {id: fields["id"], user: user, amount_cents: amount, currency: fields["currency"]}.compact +end + +# Load: strict record in, ledger entry out +LEDGER = Hash.new(0) +capability("load", + inputs: { + id: {type: "string", required: true}, + user: {type: "string", required: true}, + amount_cents: {type: "number", required: true}, + currency: {type: "string", required: true} + }, + outputs: {posted: {type: "string", required: true}}) do |record| + LEDGER[record[:currency]] += record[:amount_cents] + {posted: record[:id]} +end + +# Compose the three stages into one pipeline capability +registry.compose( + "etl_pipeline", + "Extract, transform, and load one raw event", + "1.0.0", + [{name: "extract", version: "1.0.0"}, + {name: "transform", version: "1.0.0"}, + {name: "load", version: "1.0.0"}], + lambda do |providers, inputs| + extract, transform, load = providers + record = transform.execute(extract.execute(raw: inputs[:raw])) + load.execute(record) + end +) + +pipeline = registry.get_provider("etl_pipeline") + +puts "PIPELINE RUN (#{RAW_EVENTS.size} raw events)" +puts +RAW_EVENTS.each do |raw| + posted = pipeline.execute(raw: raw) + puts " POSTED #{posted[:posted]}" +rescue Agentic::Errors::ValidationError => e + puts " REJECTED #{raw[/id=([^|]+)/, 1]} at the '#{e.capability}' #{e.kind} boundary:" + e.violations.each { |key, messages| puts " #{key}: #{Array(messages).join(", ")}" } +end + +puts +puts "LEDGER (only facts made it this far):" +LEDGER.each { |currency, cents| puts format(" %s %10.2f", currency, cents / 100.0) } From 77aef71ec2b5117f4882f201be8952b9dec7bd70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:29:33 +0000 Subject: [PATCH 21/46] docs: durable batch crash-and-resume example (mperham round 2) A forked child dies with exit! mid-batch; the parent replays the ExecutionJournal and finishes without re-paying for journaled work. Field notes on crash-testing durability and the idempotency-key gap it exposed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/08-mperham.md | 51 ++++++++++++ examples/durable_batch.rb | 104 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/perspectives/round-2/08-mperham.md create mode 100644 examples/durable_batch.rb diff --git a/docs/perspectives/round-2/08-mperham.md b/docs/perspectives/round-2/08-mperham.md new file mode 100644 index 0000000..3c2e419 --- /dev/null +++ b/docs/perspectives/round-2/08-mperham.md @@ -0,0 +1,51 @@ +# Round 2 field notes — Mike Perham builds the Durable Batch + +*Built: `examples/durable_batch.rb` — six billable calls, a real +`exit!` mid-batch, and a resume that pays only for what the journal +can't prove was finished.* + +## What I built and why + +The demo every durability claim owes its users: don't *simulate* a +crash, **have one**. The batch runs in a forked child that dies with +`Process.exit!(97)` in the middle of invoice-4 — no `ensure`, no +`at_exit`, the honest `kill -9`. Then the parent process replays the +journal and finishes the batch: + +``` +!! power cut during invoice-4 - process dying with exit!(97) +journal replay: 3 invoice(s) already paid: invoice-1, invoice-2, invoice-3 +run 2: processing 3 invoice(s): invoice-4, invoice-5, invoice-6 +total spend: $1.75 for 6 invoices (naive rerun-everything: $2.50) +``` + +Seven calls paid for six invoices — the one unavoidable double-pay is +the call that was mid-flight when the power died (that's what +idempotency keys at the API layer are for). The naive rerun costs ten. +At example prices that's $0.75; at real batch sizes it's the difference +between "rerun it" being a shrug and being a budget meeting. + +## What the crash taught, beyond the point of it + +- The fsync-per-event decision from round 1 got its vindication: + `exit!` discards everything buffered — including, amusingly, the + child's *narration* (`$stdout.sync = true` restored the story, and if + journal lines had been buffered the way stdout was, the receipt would + be fiction). Durability you haven't crash-tested is a rumor. +- Found a real gap in my own round-1 design: `task_succeeded` events + carry the task *id* but not its *description*, and run 2's task ids + are new UUIDs — so mapping "what's done" back to "which invoice" meant + joining `task_started` events by hand. The journal should carry a + caller-supplied idempotency key on every event. My gap, my next PR. +- Resume is still caller-assembled: replay, diff, rebuild the + orchestrator with the remainder. It's eight honest lines, but + `PlanOrchestrator.resume(journal:, tasks:)` is the one-liner this + example proves the API is ready for. + +## Verdict + +Boring works: append, fsync, replay, skip. The framework's hooks let +durability be an accessory instead of a rewrite, and the crash test +passed on the first honest kill. Ship the idempotency key and the +`resume` helper, and this example becomes the README section titled +"when the deploy hits mid-plan." diff --git a/examples/durable_batch.rb b/examples/durable_batch.rb new file mode 100644 index 0000000..5ceb8eb --- /dev/null +++ b/examples/durable_batch.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# The Durable Batch: six billable "LLM calls" run under an +# ExecutionJournal. Mid-batch, the process dies for real - exit!, no +# cleanup, the honest kill -9. Then a second process replays the +# journal and finishes the batch WITHOUT re-paying for completed work. +# +# bundle exec ruby examples/durable_batch.rb +# +# Runs offline; the "API" is sleep plus an invoice counter. The number +# to watch is the last one: total paid should equal the batch size, +# crash or no crash. + +require_relative "../lib/agentic" +require "tmpdir" + +# exit! discards buffered IO - the child's narration would die with it +$stdout.sync = true + +JOURNAL = File.join(Dir.tmpdir, "agentic_durable_batch.journal.jsonl") +File.delete(JOURNAL) if File.exist?(JOURNAL) + +INVOICES = %w[invoice-1 invoice-2 invoice-3 invoice-4 invoice-5 invoice-6].freeze +COST_PER_CALL = 0.25 # dollars of imaginary tokens + +# Bills the imaginary API, then optionally dies like a deploy +BillableAgent = Struct.new(:crash_on, :billed) do + def execute(prompt) + invoice = prompt[/invoice-\d+/] + sleep(0.05) # the API call we pay for + billed << invoice + + if invoice == crash_on + puts " !! power cut during #{invoice} - process dying with exit!(97)" + Process.exit!(97) # no ensure blocks, no at_exit - a real crash + end + + {"invoice" => invoice, "status" => "paid"} + end +end + +Desk = Struct.new(:agent) do + def get_agent_for_task(_task) + agent + end +end + +def run_batch(label, skip: [], crash_on: nil) + journal = Agentic::ExecutionJournal.new(path: JOURNAL) + billed = [] + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, # deterministic order, one call in flight + lifecycle_hooks: journal.lifecycle_hooks + ) + + todo = INVOICES - skip + todo.each do |invoice| + orchestrator.add_task(Agentic::Task.new( + description: invoice, + agent_spec: {"name" => "Biller", "instructions" => "Process #{invoice}"}, + input: {} + )) + end + + puts "#{label}: processing #{todo.size} invoice(s): #{todo.join(", ")}" + orchestrator.execute_plan(Desk.new(BillableAgent.new(crash_on, billed))) + billed +end + +# Maps journal task ids back to invoice names via task_started events +def completed_invoices(state) + names = state.events.each_with_object({}) do |event, map| + map[event[:task_id]] = event[:description] if event[:event] == "task_started" + end + state.completed_task_ids.map { |id| names[id] } +end + +# --- Run 1: a child process that will not survive invoice-4 --------------- +child = fork do + run_batch("run 1", crash_on: "invoice-4") +end +_, status = Process.wait2(child) +puts " child exited with status #{status.exitstatus} (journal survived on disk)" +puts + +# --- The journal knows exactly what was paid for -------------------------- +state = Agentic::ExecutionJournal.replay(path: JOURNAL) +paid = completed_invoices(state) +puts "journal replay: #{paid.size} invoice(s) already paid: #{paid.join(", ")}" +puts " #{INVOICES.size - paid.size} remain (including the one mid-flight when we died)" +puts + +# --- Run 2: same batch, skip what the journal proves is done -------------- +billed_in_run2 = run_batch("run 2", skip: paid) +puts + +total_calls = paid.size + 1 + billed_in_run2.size # +1: paid for, then died during +naive_calls = paid.size + 1 + INVOICES.size # rerunning the whole batch +puts "RECEIPT" +puts " run 1 paid: #{paid.size + 1} calls (#{paid.size} journaled + 1 lost to the crash)" +puts " run 2 paid: #{billed_in_run2.size} calls" +puts format(" total spend: $%.2f for %d invoices (naive rerun-everything: $%.2f)", + total_calls * COST_PER_CALL, INVOICES.size, naive_calls * COST_PER_CALL) +puts " journal: #{JOURNAL}" From 5784f7da24c396f8f5c35e6e7af42fdf680735bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:30:53 +0000 Subject: [PATCH 22/46] docs: refactoring dojo example (Sandi Metz round 2) Three deterministic critic agents (rules, squint test, naming) review a method in parallel; the sensei prescribes one smallest next step. Reviews the gem's own 90-line schedule_task. Field notes on multi-perspective review being buildable today from existing primitives. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-2/09-sandimetz.md | 60 ++++++++++ examples/refactoring_dojo.rb | 136 ++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 docs/perspectives/round-2/09-sandimetz.md create mode 100644 examples/refactoring_dojo.rb diff --git a/docs/perspectives/round-2/09-sandimetz.md b/docs/perspectives/round-2/09-sandimetz.md new file mode 100644 index 0000000..50b1530 --- /dev/null +++ b/docs/perspectives/round-2/09-sandimetz.md @@ -0,0 +1,60 @@ +# Round 2 field notes — Sandi Metz builds the Refactoring Dojo + +*Built: `examples/refactoring_dojo.rb` — three critic agents review a +method from three distinct perspectives in parallel; the sensei +prescribes exactly one next step.* + +## What I built and why + +A dojo, because review is a practice, not a gate. Three critics, three +*genuinely different* ways of seeing: the **rule keeper** counts (lines, +parameters — my rules are shorthand for "would you have to scroll to +lie about this method?"), the **squint tester** looks at shape (changes +in indentation are changes in concept), and the **name watcher** reads +the words (a `result` that appears nine times is a name refusing to +tell you what it holds). Each critic is an agent with one capability; +the orchestrator convenes the circle in parallel. + +Today's student, fittingly, is the gem itself — `schedule_task`, the +90-line second-place finisher on Aaron's suspects list: + +``` +rule keeper: 90 lines; the rule is five. +squint tester: 5 levels of shape change - each ridge is a concept + asking for its own method. +name watcher: 'result' appears 9x - a name that could mean anything + means nothing. +``` + +And the part I care most about — the sensei returns **one** step, not +three. A review that hands you every finding at once is a wall; a +practice hands you the smallest safe move and says "come back." +Refactoring is many small safe steps, not one brave rewrite. + +## What building with it taught me + +- Multiple-perspective review is what this framework's *architecture + documents* promise (the CriticFramework, multi-perspective + evaluation), and here's the encouraging news: the primitives that + exist — agents, capabilities, parallel tasks — were enough to build it + in a page. The vision isn't vaporware; it's an afternoon of + composition away. The documents should point at working code like + this instead of at unbuilt hubs. +- The critics measure; they do not opine. Deterministic critics agree + with themselves tomorrow, which is what makes them teachable — a + student can predict the critic, and predicting the critic IS the + lesson internalized. (An LLM critic belongs in the circle too, but as + a fourth voice, not the referee.) +- I'll say the quiet part about the adapter one more time, gently: my + `Dojo` provider found each critic by matching `task.description` + against an agent's name — string-keyed identity for objects I was + holding in my hand. Six of us have now written this same workaround. + The framework is being told something by its users; the polite thing + is to answer. + +## Verdict + +The gem let me express a event-of-practice — circle convenes, sees +differently, prescribes smally — in code a workshop attendee could read +over coffee. That's the test of a framework's vocabulary: can you teach +with it. You can. diff --git a/examples/refactoring_dojo.rb b/examples/refactoring_dojo.rb new file mode 100644 index 0000000..83bcc79 --- /dev/null +++ b/examples/refactoring_dojo.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +# The Refactoring Dojo: a student submits a method, three critic agents +# review it from three distinct perspectives, and the sensei prescribes +# the ONE smallest next step. Today's student: this gem itself, +# submitting its second-longest method (schedule_task, 90 lines). +# +# bundle exec ruby examples/refactoring_dojo.rb [file] [method] +# +# Runs offline: the critics measure; they don't guess. + +require_relative "../lib/agentic" +require "prism" + +file = ARGV[0] || File.expand_path("../lib/agentic/plan_orchestrator.rb", __dir__) +method_name = (ARGV[1] || "schedule_task").to_sym + +# Find the student's submission +def find_def(node, name) + return node if node.is_a?(Prism::DefNode) && node.name == name + + node&.child_nodes&.each do |child| + found = find_def(child, name) + return found if found + end + nil +end + +submission = find_def(Prism.parse_file(file).value, method_name) || + abort("no method #{method_name} in #{file}") +source = submission.slice +lines = source.lines + +def critic(name, &impl) + spec = Agentic::CapabilitySpecification.new( + name: name, description: "The #{name} critic", version: "1.0.0", + inputs: {source: {type: "string", required: true}}, + outputs: {findings: {type: "array", required: true}} + ) + Agentic.register_capability( + spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) + ) + + Agentic::Agent.build { |a| a.name = name }.tap { |a| a.add_capability(name) } +end + +critics = [] + +critics << critic("rule_keeper") do |input| + body = input[:source].lines + findings = [] + if body.size > 5 + findings << "#{body.size} lines; the rule is five. Every extra line is a place for a bug to live." + end + params = body.first[/\((.*)\)/, 1].to_s.split(",").size + if params > 4 + findings << "#{params} parameters; four is the ceiling before a parameter object is cheaper." + end + {findings: findings} +end + +critics << critic("squint_tester") do |input| + body = input[:source].lines + indents = body.map { |l| l[/\A */].size }.reject(&:zero?) + depth = (indents.max - indents.min) / 2 + findings = [] + if depth >= 3 + findings << "squinting shows #{depth} levels of shape change - each ridge is a concept asking for its own method." + end + branches = body.count { |l| l.strip.start_with?("if ", "elsif ", "unless ", "when ", "rescue") } + if branches >= 4 + findings << "#{branches} branch points in one method - this method makes decisions AND does work; split the two." + end + {findings: findings} +end + +critics << critic("name_watcher") do |input| + body = input[:source] + findings = [] + vague = body.scan(/\b(data|info|result|temp|obj|thing)\b/).flatten.tally + vague.each do |word, count| + findings << "'#{word}' appears #{count}x - a name that could mean anything means nothing. What IS it?" + end + if body.match?(/def \w+_and_\w+/) + findings << "the name contains 'and' - a confession that this is two methods in one costume." + end + {findings: findings} +end + +# The circle convenes: each critic reviews in parallel +Dojo = Struct.new(:critics, :scrolls, :source) do + def get_agent_for_task(task) + dojo = self + critic = critics.find { |c| c.name == task.description } + Object.new.tap do |seat| + seat.define_singleton_method(:execute) do |_prompt| + dojo.scrolls[critic.name] = + critic.execute_capability(critic.name, {source: dojo.source})[:findings] + "reviewed" + end + end + end +end + +scrolls = {} +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) +critics.each do |c| + orchestrator.add_task(Agentic::Task.new( + description: c.name, + agent_spec: {"name" => c.name, "instructions" => "Review the submission"}, + input: {} + )) +end +orchestrator.execute_plan(Dojo.new(critics, scrolls, source)) + +puts "REFACTORING DOJO" +puts "submission: ##{method_name} (#{lines.size} lines) from #{File.basename(file)}" +puts +scrolls.each do |critic_name, findings| + puts "#{critic_name.tr("_", " ")} says:" + findings.each { |f| puts " - #{f}" } + puts " - no complaints. rare." if findings.empty? + puts +end + +total = scrolls.values.sum(&:size) +puts "sensei's prescription:" +if total.zero? + puts " ship it, then find a harder kata." +else + first_move = scrolls["squint_tester"]&.first || scrolls.values.flatten.first + puts " #{total} findings, ONE next step - the smallest one:" + puts " start where the squint test hurts: #{first_move}" + puts " make that change, run the tests, come back. refactoring is many small" + puts " safe steps, not one brave rewrite." +end From e2883896402cc200b8e9d2b77d408f31632935ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:32:58 +0000 Subject: [PATCH 23/46] docs: gem scout example and round-2 index (ankane round 2) Gem Scout rides the pluggable web_search backend with an offline index and a separate scoring capability. The perspectives README gains the round-2 table plus the consumer-side consensus: tasks need payloads, orchestrators should accept agents directly, dependents need output piping. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/README.md | 43 +++++++++- docs/perspectives/round-2/10-ankane.md | 57 +++++++++++++ examples/gem_scout.rb | 107 +++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 docs/perspectives/round-2/10-ankane.md create mode 100644 examples/gem_scout.rb diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md index 2646c40..fd54fb6 100644 --- a/docs/perspectives/README.md +++ b/docs/perspectives/README.md @@ -9,7 +9,7 @@ each persona proposed**, taking field notes in character along the way. > well-known public work and stated philosophies. They are not the actual > opinions of the people named. -## The reviews, the builds, the field notes +## Round 1 — reviewing and repairing the framework | # | Persona | Lens | What they built | Field notes | |---|---------|------|-----------------|-------------| @@ -25,6 +25,47 @@ each persona proposed**, taking field notes in character along the way. | 9 | Sandi Metz | Small objects, honest messages | `execute_with_schema` honesty + subclass-safe factory | [09-sandimetz.md](field-notes/09-sandimetz.md) | | 10 | Andrew Kane | Practical ML gems | Pluggable `web_search` capability backend | [10-ankane.md](field-notes/10-ankane.md) | +## Round 2 — building *with* the gem + +Each persona then built something novel **using** Agentic as a consumer — +every program under `examples/` runs offline, and the field notes record +what building on the framework actually felt like. + +| # | Persona | Built with the gem | Run it | Field notes | +|---|---------|--------------------|--------|-------------| +| 1 | Matz | Renga circle — dependency graphs as poetic form | `examples/renga_circle.rb` | [round-2/01-matz.md](round-2/01-matz.md) | +| 2 | DHH | HEY-style ticket screener (parallel capability pipeline) | `examples/ticket_screener.rb` | [round-2/02-dhh.md](round-2/02-dhh.md) | +| 3 | Aaron Patterson | Performance Detective — Prism audits the gem's own methods | `examples/performance_detective.rb` | [round-2/03-tenderlove.md](round-2/03-tenderlove.md) | +| 4 | Xavier Noria | Namespace Cartographer — maps constant trees, audits conformance | `examples/namespace_cartographer.rb` | [round-2/04-fxn.md](round-2/04-fxn.md) | +| 5 | Samuel Williams | Latency Lab — measured fan-out scaling + reactor cohabitation | `examples/latency_lab.rb` | [round-2/05-ioquatix.md](round-2/05-ioquatix.md) | +| 6 | Jeremy Evans | Schema Advisor — deterministic DBA rules as capabilities | `examples/schema_advisor.rb` | [round-2/06-jeremyevans.md](round-2/06-jeremyevans.md) | +| 7 | Piotr Solnica | Typed ETL pipeline — contracts stop bad data at named boundaries | `examples/typed_pipeline.rb` | [round-2/07-solnic.md](round-2/07-solnic.md) | +| 8 | Mike Perham | Durable Batch — real `exit!` mid-run, resume without re-paying | `examples/durable_batch.rb` | [round-2/08-mperham.md](round-2/08-mperham.md) | +| 9 | Sandi Metz | Refactoring Dojo — three critics, one prescribed next step | `examples/refactoring_dojo.rb` | [round-2/09-sandimetz.md](round-2/09-sandimetz.md) | +| 10 | Andrew Kane | Gem Scout — search + score pipeline on the pluggable backend | `examples/gem_scout.rb` | [round-2/10-ankane.md](round-2/10-ankane.md) | + +### What round 2 taught (the consumer's consensus) + +Building *with* the gem surfaced different findings than reviewing it: + +1. **Tasks need a payload, and the orchestrator should accept agents or + callables directly.** Six personas independently wrote the same two + workarounds: smuggling domain objects through `task.description` and + wrapping an agent they already had in a `get_agent_for_task` provider + struct. That's the API's users voting. +2. **Dependent tasks can't see each other's outputs** (Matz hit it first + and hardest): the orchestrator schedules around dependencies but + doesn't pipe results into dependents, forcing shared mutable state. +3. **The concurrency story is real and needs one honest paragraph**: + near-ideal scaling for IO-bound tasks (Samuel measured within 10ms of + theoretical), nothing for CPU-bound work (Aaron measured that too). +4. **Capabilities-as-lambdas is the gem's best idea.** Every build used + them; contracts (round 1's validator) caught real mistakes during + development in three of the ten builds. +5. **Start with capabilities, add the orchestrator when there's a + queue.** The builds that didn't fan out (typed pipeline, gem scout) + were better off without it. + --- ## 1. Yukihiro "Matz" Matsumoto — optimizing for happiness diff --git a/docs/perspectives/round-2/10-ankane.md b/docs/perspectives/round-2/10-ankane.md new file mode 100644 index 0000000..d8d6467 --- /dev/null +++ b/docs/perspectives/round-2/10-ankane.md @@ -0,0 +1,57 @@ +# Round 2 field notes — Andrew Kane builds Gem Scout + +*Built: `examples/gem_scout.rb` — describe what you need, get a ranked +shortlist of gems: search capability finds candidates, a scoring +capability ranks them on adoption and maintenance.* + +## What I built and why + +The tool I actually use my own judgment for every week, as a pipeline: +`web_search` (the capability from my round 1, riding its pluggable +backend seam) finds candidates, `score_gem` ranks them on the things +that matter when you have to *live* with a dependency — adoption +(log-scale downloads) and release freshness — and the scout prints a +shortlist with reasons: + +``` +GEM SCOUT: "background jobs" +-> sidekiq 97.4 widely adopted (950M downloads); recently released + good_job 70.5 recently released (14d ago) + solid_queue 60.2 recently released (30d ago) +``` + +Offline by default: the backend lambda serves a bundled index shaped +exactly like live search results, so the program can't tell the +difference — and going live is one assignment +(`WebSearch.backend = DuckDuckGo.new`). That seam existing is the +whole reason this example is twenty minutes of work instead of a +weekend. + +## What building with it confirmed + +- **Separating find from judge is the pattern.** Search returns + candidates; scoring is a different capability with different inputs + and its own contract. When I wire this to real data (rubygems.org API + for downloads, GitHub for commit recency), only `score_gem`'s lambda + changes. Capabilities as small swappable units is this gem's best + idea, and it held up across all ten of these builds. +- My scoring exposed its own bias immediately: "vector search" + recommends searchkick (130M downloads) over neighbor, which is the + *actually correct* answer for the query. Popularity-weighted ranking + recommends incumbents. Real Gem Scout needs a relevance term the + search score already computed — and the pipeline made that gap + visible in one run, which is what pipelines with visible seams are + for. +- No orchestrator needed: two capability calls in sequence. I keep + score of when the personas reached for `PlanOrchestrator` versus + plain capability calls — it was worth its adapter tax exactly when + there was real fan-out (files, tickets, tables) and not before. + Frameworks should say that in the README: start with capabilities, + add the orchestrator when you have a queue. + +## What I'd ship next + +Wire `score_gem` to the rubygems.org API (downloads, latest version +date) and add a `bundle add` prompt at the end. At that point this +stops being an example and becomes a gem — `gem_scout` — which is the +bar examples should aim for. diff --git a/examples/gem_scout.rb b/examples/gem_scout.rb new file mode 100644 index 0000000..14dc029 --- /dev/null +++ b/examples/gem_scout.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Gem Scout: describe what you need, get a ranked shortlist of gems. +# Search and scoring are separate capabilities; the search backend is +# the pluggable seam - offline it's a bundled index, online swap in +# the real WebSearch DuckDuckGo backend with one assignment. +# +# bundle exec ruby examples/gem_scout.rb "background jobs" +# bundle exec ruby examples/gem_scout.rb "vector search" +# +# Runs offline by default. + +require_relative "../lib/agentic" + +# A small index standing in for the network - same shape a live search +# backend returns, so the rest of the program can't tell the difference +CATALOG = [ + {name: "sidekiq", summary: "background jobs backed by Redis, threads not forks", + topics: %w[background jobs queue async workers], downloads_m: 950, last_release_days: 20}, + {name: "solid_queue", summary: "database-backed background jobs for Active Job", + topics: %w[background jobs queue rails database], downloads_m: 15, last_release_days: 30}, + {name: "good_job", summary: "Postgres-based Active Job backend with dashboard", + topics: %w[background jobs queue rails postgres], downloads_m: 40, last_release_days: 14}, + {name: "neighbor", summary: "nearest neighbor vector search for Rails and Postgres", + topics: %w[vector search embeddings pgvector similarity], downloads_m: 8, last_release_days: 45}, + {name: "pgvector", summary: "pgvector support for Ruby", + topics: %w[vector search embeddings postgres], downloads_m: 12, last_release_days: 60}, + {name: "searchkick", summary: "intelligent search made easy with Elasticsearch/OpenSearch", + topics: %w[search elasticsearch full-text ranking], downloads_m: 130, last_release_days: 90}, + {name: "pagy", summary: "the fastest pagination gem", + topics: %w[pagination performance views], downloads_m: 85, last_release_days: 10}, + {name: "strong_migrations", summary: "catch unsafe migrations in development", + topics: %w[migrations database safety postgres], downloads_m: 70, last_release_days: 25} +].freeze + +# Offline backend for the WebSearch seam built in round 1 +Agentic::Capabilities::WebSearch.backend = lambda do |query:, num_results:| + terms = query.downcase.split + hits = CATALOG.map { |gem| + haystack = "#{gem[:name]} #{gem[:summary]} #{gem[:topics].join(" ")}" + score = terms.count { |t| haystack.include?(t) } + [gem, score] + }.select { |_, s| s.positive? }.sort_by { |g, s| [-s, -g[:downloads_m]] }.first(num_results) + + { + results: hits.map { |gem, _| "#{gem[:name]}: #{gem[:summary]}" }, + sources: hits.map { |gem, _| "https://rubygems.org/gems/#{gem[:name]}" } + } +end + +# Scoring is its own capability: search finds candidates, this ranks +# them on the things that matter when you have to live with a gem +spec = Agentic::CapabilitySpecification.new( + name: "score_gem", + description: "Score a gem on adoption and maintenance", + version: "1.0.0", + inputs: {name: {type: "string", required: true}}, + outputs: {score: {type: "number", required: true}, notes: {type: "array", required: true}} +) +Agentic.register_capability(spec, Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { + gem = CATALOG.find { |g| g[:name] == inputs[:name] } or + next({score: 0.0, notes: ["unknown gem"]}) + + adoption = Math.log10([gem[:downloads_m], 1].max) / 3.0 # 0..1 for 1M..1B + freshness = [1.0 - gem[:last_release_days] / 365.0, 0].max + notes = [] + notes << "widely adopted (#{gem[:downloads_m]}M downloads)" if gem[:downloads_m] > 50 + notes << "recently released (#{gem[:last_release_days]}d ago)" if gem[:last_release_days] < 31 + notes << "check maintenance cadence" if gem[:last_release_days] > 80 + + {score: ((adoption * 0.6 + freshness * 0.4) * 100).round(1), notes: notes} + } +)) + +scout = Agentic::Agent.build { |a| a.name = "GemScout" } +Agentic::Capabilities.register_standard_capabilities +scout.add_capability("web_search") +scout.add_capability("score_gem") + +need = ARGV.join(" ") +need = "background jobs" if need.empty? + +found = scout.execute_capability("web_search", {query: need, num_results: 4}) +candidates = found[:results].map { |line| line.split(":").first } + +ranked = candidates.map { |name| + verdict = scout.execute_capability("score_gem", {name: name}) + {name: name, score: verdict[:score], notes: verdict[:notes]} +}.sort_by { |c| -c[:score] } + +puts "GEM SCOUT: \"#{need}\"" +puts +if ranked.empty? + puts " no candidates found - try different words, or plug in the live backend:" + puts " Agentic::Capabilities::WebSearch.backend = Agentic::Capabilities::WebSearch::DuckDuckGo.new" +else + ranked.each_with_index do |c, i| + marker = (i == 0) ? "->" : " " + puts format("%s %-18s %5.1f %s", marker, c[:name], c[:score], c[:notes].join("; ")) + end + puts + winner = ranked.first + puts "recommendation: start with #{winner[:name]} - " \ + "#{CATALOG.find { |g| g[:name] == winner[:name] }[:summary]}" +end From f1b0a84302cb26715c647e5ce3e9c41ff8916e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:07:38 +0000 Subject: [PATCH 24/46] feat: deliver the round-2 consumer roadmap The consensus from ten personas building with the gem, implemented: - Task#payload carries arbitrary domain objects, opaque to the framework - add_task(task, deps, agent:) attaches an agent or bare callable directly; callables receive the Task and their return value becomes the output (CallableAgent adapter) - Dependency output piping: the orchestrator feeds completed dependency outputs into dependents (Task#dependency_outputs, Task#output_of); dependencies may be Task objects or ids - execute_plan's provider is now optional (fails fast when unresolvable) and accepts a block as a plan-wide agent factory - TaskDefinition#to_task bridges planner output to orchestrator input - registry.compose accepts inputs:/outputs: so composed capabilities get boundary validation like primitive ones - ExecutionJournal events carry task descriptions as cross-run idempotency keys; ReplayedState#completed? answers by id or name - README documents the concurrency contract (IO scales, CPU doesn't), direct-agent usage, and which layer to start with All six round-2 examples that carried the provider-adapter workaround are rewritten on the new API - each got shorter and lost its shared mutable state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- README.md | 58 ++++++++ examples/namespace_cartographer.rb | 31 ++-- examples/performance_detective.rb | 33 ++--- examples/refactoring_dojo.rb | 32 ++-- examples/renga_circle.rb | 54 +++---- examples/schema_advisor.rb | 34 ++--- examples/ticket_screener.rb | 44 +++--- lib/agentic.rb | 10 +- lib/agentic/agent_capability_registry.rb | 11 +- lib/agentic/callable_agent.rb | 26 ++++ lib/agentic/execution_journal.rb | 26 +++- lib/agentic/plan_orchestrator.rb | 70 ++++++++- lib/agentic/task.rb | 29 +++- lib/agentic/task_definition.rb | 8 + spec/agentic/capability_validator_spec.rb | 35 +++++ spec/agentic/execution_journal_spec.rb | 25 ++++ .../plan_orchestrator_direct_agents_spec.rb | 138 ++++++++++++++++++ 17 files changed, 493 insertions(+), 171 deletions(-) create mode 100644 lib/agentic/callable_agent.rb create mode 100644 spec/agentic/plan_orchestrator_direct_agents_spec.rb diff --git a/README.md b/README.md index e35077e..efba3a8 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,64 @@ else end ``` +### Passing work to the orchestrator directly + +You don't need an agent provider to run a plan. Tasks carry an arbitrary +`payload`, accept their agent (or a bare callable) directly, and receive the +outputs of the tasks they depend on: + +```ruby +orchestrator = Agentic::PlanOrchestrator.new + +fetch = Agentic::Task.new( + description: "fetch the order", + agent_spec: {"name" => "Fetcher", "instructions" => "fetch"}, + payload: order_id # any domain object, opaque to the framework +) +notify = Agentic::Task.new( + description: "notify the customer", + agent_spec: {"name" => "Notifier", "instructions" => "notify"} +) + +# A callable receives the Task itself; its return value becomes the output +orchestrator.add_task(fetch, agent: ->(task) { OrderApi.fetch(task.payload) }) + +# Dependencies pipe their outputs into dependents +orchestrator.add_task(notify, [fetch], agent: ->(task) { + Mailer.deliver(task.output_of(fetch)) # or task.dependency_outputs +}) + +result = orchestrator.execute_plan # no provider needed +``` + +A plan-wide block acts as an agent factory when you do want one place that +builds agents: `orchestrator.execute_plan { |task| build_agent_for(task) }`. + +### The concurrency contract + +Tasks run as fibers inside an [async](https://github.com/socketry/async) +reactor. That means: + +- **IO-bound tasks scale nearly perfectly.** LLM calls, HTTP requests, + `sleep`, database queries — anything that waits on IO yields to other + tasks. Twenty 200ms API calls at `concurrency_limit: 20` take ~200ms of + wall clock, not 4 seconds (see `examples/latency_lab.rb` for the measured + curve). +- **CPU-bound tasks do not parallelize.** Fibers share one thread; parsing, + hashing, and number crunching gain nothing from a higher concurrency + limit. If your tasks are compute, the limit is a queue, not a speedup. +- `execute_plan` composes with a running reactor: called inside `Async` + (e.g. under Falcon), it joins the current event loop instead of nesting + a new one; standalone, it creates its own and blocks until done. + +### When to reach for which layer + +Start with **capabilities** — lambdas with declared contracts — and call +them directly. Add the **orchestrator** when you have an actual queue: +many independent items, or tasks with dependencies. Add the **planner** +(`Agentic.run` / `TaskPlanner`) when the task list itself should come from +an LLM. Each layer is optional and composes with the ones below it. + ## Extension System Agentic includes a powerful Extension System to help integrate with external systems and customize the framework's behavior. The Extension System consists of three main components: diff --git a/examples/namespace_cartographer.rb b/examples/namespace_cartographer.rb index 2b55c1f..647717e 100644 --- a/examples/namespace_cartographer.rb +++ b/examples/namespace_cartographer.rb @@ -67,30 +67,21 @@ def collect_definitions(node, namespace, found) surveyor = Agentic::Agent.build { |a| a.name = "Cartographer" } surveyor.add_capability("survey_file") -Expedition = Struct.new(:agent, :charts) do - def get_agent_for_task(task) - expedition = self - Object.new.tap do |scout| - scout.define_singleton_method(:execute) do |_prompt| - expedition.charts[task.description] = - expedition.agent.execute_capability("survey_file", {path: task.description})[:defined] - "charted" - end - end - end -end - files = Dir[File.join(LIB, "**", "*.rb")].sort -charts = {} orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) -files.each do |path| - orchestrator.add_task(Agentic::Task.new( - description: path, +tasks = files.to_h do |path| + task = Agentic::Task.new( + description: File.basename(path), agent_spec: {"name" => "Cartographer", "instructions" => "Survey the file"}, - input: {} - )) + payload: path + ) + orchestrator.add_task(task, agent: ->(t) { + surveyor.execute_capability("survey_file", {path: t.payload})[:defined] + }) + [path, task] end -result = orchestrator.execute_plan(Expedition.new(surveyor, charts)) +result = orchestrator.execute_plan +charts = tasks.transform_values { |task| result.results[task.id].output } # Compare the map against the territory deviations = [] diff --git a/examples/performance_detective.rb b/examples/performance_detective.rb index f19b33c..d5ae85d 100644 --- a/examples/performance_detective.rb +++ b/examples/performance_detective.rb @@ -51,37 +51,28 @@ def collect_defs(node, found) detective = Agentic::Agent.build { |a| a.name = "Detective" } detective.add_capability("dissect_file") -# Every file is a lead; every lead gets a task -Casefile = Struct.new(:agent, :evidence) do - def get_agent_for_task(task) - casefile = self - Object.new.tap do |gumshoe| - gumshoe.define_singleton_method(:execute) do |_prompt| - report = casefile.agent.execute_capability("dissect_file", {path: task.description}) - casefile.evidence[task.description] = report - "#{report[:methods].size} methods" - end - end - end -end - +# Every file is a lead; every lead gets a task with the path as payload concurrency = (ARGV.first || 16).to_i files = Dir[File.join(LIB, "**", "*.rb")].sort -evidence = {} orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency) -files.each do |path| - orchestrator.add_task(Agentic::Task.new( - description: path, +tasks = files.map do |path| + task = Agentic::Task.new( + description: File.basename(path), agent_spec: {"name" => "Detective", "instructions" => "Dissect the file"}, - input: {} - )) + payload: path + ) + orchestrator.add_task(task, agent: ->(t) { + detective.execute_capability("dissect_file", {path: t.payload}) + }) + task end started = Process.clock_gettime(Process::CLOCK_MONOTONIC) -result = orchestrator.execute_plan(Casefile.new(detective, evidence)) +result = orchestrator.execute_plan elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round +evidence = tasks.to_h { |task| [task.payload, result.results[task.id].output] } all_methods = evidence.flat_map do |path, report| report[:methods].map { |m| m.merge(file: path.delete_prefix("#{LIB}/")) } end diff --git a/examples/refactoring_dojo.rb b/examples/refactoring_dojo.rb index 83bcc79..11231f3 100644 --- a/examples/refactoring_dojo.rb +++ b/examples/refactoring_dojo.rb @@ -87,31 +87,21 @@ def critic(name, &impl) {findings: findings} end -# The circle convenes: each critic reviews in parallel -Dojo = Struct.new(:critics, :scrolls, :source) do - def get_agent_for_task(task) - dojo = self - critic = critics.find { |c| c.name == task.description } - Object.new.tap do |seat| - seat.define_singleton_method(:execute) do |_prompt| - dojo.scrolls[critic.name] = - critic.execute_capability(critic.name, {source: dojo.source})[:findings] - "reviewed" - end - end - end -end - -scrolls = {} +# The circle convenes: each critic rides its own task and reviews in parallel orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) -critics.each do |c| - orchestrator.add_task(Agentic::Task.new( +seats = critics.to_h do |c| + task = Agentic::Task.new( description: c.name, agent_spec: {"name" => c.name, "instructions" => "Review the submission"}, - input: {} - )) + payload: c + ) + orchestrator.add_task(task, agent: ->(t) { + t.payload.execute_capability(t.payload.name, {source: source})[:findings] + }) + [c.name, task] end -orchestrator.execute_plan(Dojo.new(critics, scrolls, source)) +run = orchestrator.execute_plan +scrolls = seats.transform_values { |task| run.results[task.id].output } puts "REFACTORING DOJO" puts "submission: ##{method_name} (#{lines.size} lines) from #{File.basename(file)}" diff --git a/examples/renga_circle.rb b/examples/renga_circle.rb index c9c3700..4dba311 100644 --- a/examples/renga_circle.rb +++ b/examples/renga_circle.rb @@ -2,7 +2,8 @@ # A renga circle: three poet agents compose a linked-verse poem, each # verse responding to the one before it. The dependency graph IS the -# poem's form - verse 2 cannot begin until verse 1 exists. +# poem's form - verse 2 cannot begin until verse 1 exists, and the +# orchestrator pipes each verse into the poet who answers it. # # bundle exec ruby examples/renga_circle.rb # @@ -10,9 +11,6 @@ require_relative "../lib/agentic" -# The scroll is passed hand to hand; each poet reads what came before -scroll = [] - STYLES = { "Basho" => ->(theme, previous) { previous ? "#{previous.split.last} lingers -\n#{theme} on the temple bell\na crow shakes off rain" : "first light, #{theme} -\nthe pond remembers\nlast night's moon" @@ -53,53 +51,37 @@ [name, poet] end -# A poet-agent adapter: when the orchestrator hands it a prompt, it -# reads the scroll, composes, and appends -PoetAtTheTable = Struct.new(:poet, :capability, :theme, :scroll) do - def execute(_prompt) - verse = poet.execute_capability(capability, { - theme: theme, - previous: scroll.last - }.compact)[:verse] - scroll << verse - verse - end -end - -class RengaProvider - def initialize(seats) - @seats = seats - end - - def get_agent_for_task(task) - @seats.fetch(task.description) - end -end - theme = ARGV.first || "autumn wind" orchestrator = Agentic::PlanOrchestrator.new -seats = {} -previous_task = nil +# The circle: each poet's task depends on the previous poet's, and the +# previous verse arrives through the dependency pipe - no shared state +tasks = [] %w[Basho Buson Issa].each do |name| task = Agentic::Task.new( description: name, agent_spec: Agentic::AgentSpecification.new( name: name, description: "Renga poet", instructions: "Compose one linked verse" ), - input: {} + payload: theme ) - seats[name] = PoetAtTheTable.new(poets[name], "verse_#{name.downcase}", theme, scroll) - orchestrator.add_task(task, previous_task ? [previous_task.id] : []) - previous_task = task + + orchestrator.add_task(task, tasks.empty? ? [] : [tasks.last], agent: ->(t) { + previous = t.dependency_outputs.values.first + poets[t.description].execute_capability( + "verse_#{t.description.downcase}", + {theme: t.payload, previous: previous}.compact + )[:verse] + }) + tasks << task end -result = orchestrator.execute_plan(RengaProvider.new(seats)) +result = orchestrator.execute_plan puts " ~ a renga on \"#{theme}\" ~" puts -scroll.each_with_index do |verse, i| - puts verse.split("\n").map { |line| " #{line}" } +tasks.each do |task| + puts result.results[task.id].output.split("\n").map { |line| " #{line}" } puts end puts " (#{result.status} in #{(result.execution_time * 1000).round}ms)" diff --git a/examples/schema_advisor.rb b/examples/schema_advisor.rb index 64b9cb9..2e20c85 100644 --- a/examples/schema_advisor.rb +++ b/examples/schema_advisor.rb @@ -109,34 +109,22 @@ def register_rule(name, &impl) dba = Agentic::Agent.build { |a| a.name = "DBA" } RULES.each { |rule| dba.add_capability(rule) } -Consultation = Struct.new(:agent, :findings) do - def get_agent_for_task(task) - consultation = self - Object.new.tap do |reviewer| - reviewer.define_singleton_method(:execute) do |_prompt| - table = task.description - RULES.each do |rule| - result = consultation.agent.execute_capability(rule, { - table: table, definition: SCHEMA[table], queries: QUERY_LOG - }) - consultation.findings.concat(result[:advisories]) - end - "reviewed" - end - end - end -end - -findings = [] orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) -SCHEMA.each_key do |table| +SCHEMA.each do |table, definition| orchestrator.add_task(Agentic::Task.new( description: table, agent_spec: {"name" => "DBA", "instructions" => "Review the table"}, - input: {} - )) + payload: definition + ), agent: ->(task) { + RULES.flat_map do |rule| + dba.execute_capability(rule, { + table: task.description, definition: task.payload, queries: QUERY_LOG + })[:advisories] + end + }) end -result = orchestrator.execute_plan(Consultation.new(dba, findings)) +result = orchestrator.execute_plan +findings = result.results.values.select(&:successful?).flat_map(&:output) puts "SCHEMA REVIEW: #{SCHEMA.size} tables, #{QUERY_LOG.size} logged queries, " \ "#{findings.size} advisories (#{result.status})" diff --git a/examples/ticket_screener.rb b/examples/ticket_screener.rb index e74854f..d33f9c7 100644 --- a/examples/ticket_screener.rb +++ b/examples/ticket_screener.rb @@ -71,43 +71,35 @@ def register(name, inputs, outputs, &impl) screener = Agentic::Agent.build { |a| a.name = "Screener" } %w[screen categorize draft_reply].each { |c| screener.add_capability(c) } -# Each ticket is a task; the orchestrator fans them out in parallel -TicketDesk = Struct.new(:agent, :inbox) do - def get_agent_for_task(task) - desk = self - ticket = TICKETS.find { |t| t[:id] == task.description } - Object.new.tap do |worker| - worker.define_singleton_method(:execute) do |_prompt| - verdict = desk.agent.execute_capability("screen", ticket.slice(:from, :subject, :body))[:verdict] - entry = ticket.slice(:id, :from, :subject).merge(verdict: verdict) +# Each ticket rides its task as the payload; the work is a callable +screen_ticket = lambda do |task| + ticket = task.payload + verdict = screener.execute_capability("screen", ticket.slice(:from, :subject, :body))[:verdict] + entry = ticket.slice(:id, :from, :subject).merge(verdict: verdict) - if verdict == "in" - triage = desk.agent.execute_capability("categorize", ticket.slice(:subject, :body)) - entry[:category] = triage[:category] - entry[:urgent] = triage[:urgent] - entry[:draft] = desk.agent.execute_capability( - "draft_reply", {subject: ticket[:subject], category: triage[:category]} - )[:draft] - end - - desk.inbox << entry - entry - end - end + if verdict == "in" + triage = screener.execute_capability("categorize", ticket.slice(:subject, :body)) + entry[:category] = triage[:category] + entry[:urgent] = triage[:urgent] + entry[:draft] = screener.execute_capability( + "draft_reply", {subject: ticket[:subject], category: triage[:category]} + )[:draft] end + + entry end -inbox = [] orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 5) TICKETS.each do |ticket| orchestrator.add_task(Agentic::Task.new( description: ticket[:id], agent_spec: {"name" => "Screener", "instructions" => "Screen this ticket"}, - input: {} - )) + payload: ticket + ), agent: screen_ticket) end -result = orchestrator.execute_plan(TicketDesk.new(screener, inbox)) +result = orchestrator.execute_plan +inbox = result.results.values.select(&:successful?).map(&:output) screened_in = inbox.select { |t| t[:verdict] == "in" }.sort_by { |t| t[:urgent] ? 0 : 1 } screened_out = inbox - screened_in diff --git a/lib/agentic.rb b/lib/agentic.rb index 07ed2d5..156577e 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -90,15 +90,7 @@ def self.run(goal, model: nil, concurrency: 5) plan = TaskPlanner.new(goal, config).plan orchestrator = PlanOrchestrator.new(concurrency_limit: concurrency) - plan.tasks.each do |task_def| - orchestrator.add_task( - Task.new( - description: task_def.description, - agent_spec: task_def.agent, - input: {} - ) - ) - end + plan.tasks.each { |task_def| orchestrator.add_task(task_def.to_task) } orchestrator.execute_plan(DefaultAgentProvider.new(config)) end diff --git a/lib/agentic/agent_capability_registry.rb b/lib/agentic/agent_capability_registry.rb index 2b1eb8e..7b7bc48 100644 --- a/lib/agentic/agent_capability_registry.rb +++ b/lib/agentic/agent_capability_registry.rb @@ -115,13 +115,20 @@ def list(include_providers: false) end # Compose capabilities into a new capability + # + # The composition may declare its own inputs/outputs contract; when it + # does, the composed capability is validated at its boundary exactly + # like a primitive one. + # # @param name [String] The name of the composed capability # @param description [String] Description of the composed capability # @param version [String] The version of the composed capability # @param capabilities [Array] The capabilities to compose # @param compose_fn [Proc] The function to use for composition + # @param inputs [Hash] Optional declared inputs for the composition as a whole + # @param outputs [Hash] Optional declared outputs for the composition as a whole # @return [CapabilitySpecification] The composed capability - def compose(name, description, version, capabilities, compose_fn) + def compose(name, description, version, capabilities, compose_fn, inputs: {}, outputs: {}) # Get the individual capabilities capability_instances = [] capability_providers = [] @@ -152,6 +159,8 @@ def compose(name, description, version, capabilities, compose_fn) name: name, description: description, version: version, + inputs: inputs, + outputs: outputs, dependencies: dependencies ) diff --git a/lib/agentic/callable_agent.rb b/lib/agentic/callable_agent.rb new file mode 100644 index 0000000..4226e42 --- /dev/null +++ b/lib/agentic/callable_agent.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Agentic + # Adapts a bare callable into the agent interface the orchestrator + # expects. The callable receives the Task itself - payload, input, + # dependency outputs and all - and its return value becomes the task's + # output: + # + # orchestrator.add_task(task, agent: ->(t) { process(t.payload) }) + class CallableAgent + # @param callable [#call] The work, called with the task + # @param task [Task] The task this agent executes + def initialize(callable, task) + @callable = callable + @task = task + end + + # Executes the callable with the task (the prompt is derivable from + # the task, so callables receive the richer object) + # @param _prompt [String] Ignored - the callable gets the task + # @return [Object] The callable's return value, as task output + def execute(_prompt) + @callable.call(@task) + end + end +end diff --git a/lib/agentic/execution_journal.rb b/lib/agentic/execution_journal.rb index 2403cf2..103044a 100644 --- a/lib/agentic/execution_journal.rb +++ b/lib/agentic/execution_journal.rb @@ -23,13 +23,21 @@ module Agentic class ExecutionJournal # Replayed journal state: everything a resuming process needs to know ReplayedState = Struct.new( - :plan_id, :status, :completed_task_ids, :failed_task_ids, :outputs, :failures, :events, + :plan_id, :status, :completed_task_ids, :failed_task_ids, :outputs, :failures, :events, :descriptions, keyword_init: true ) do - # @param task_id [String] The task to check + # @param key [String] A task id or a task description (descriptions + # act as idempotency keys across runs, since ids are per-run) # @return [Boolean] True if the journal records a success for the task - def completed?(task_id) - completed_task_ids.include?(task_id) + def completed?(key) + completed_task_ids.include?(key) || completed_descriptions.include?(key) + end + + # Descriptions of every task the journal proves completed - the + # resume set for a rerun, where task ids are freshly generated + # @return [Array] + def completed_descriptions + completed_task_ids.filter_map { |task_id| descriptions[task_id] } end end @@ -52,10 +60,10 @@ def lifecycle_hooks(hooks = {}) record(:task_started, task_id: task_id, description: task.description) end, after_task_success: chain(hooks[:after_task_success]) do |task_id:, task:, result:, duration:| - record(:task_succeeded, task_id: task_id, duration: duration, output: result.output) + record(:task_succeeded, task_id: task_id, description: task.description, duration: duration, output: result.output) end, after_task_failure: chain(hooks[:after_task_failure]) do |task_id:, task:, failure:, duration:| - record(:task_failed, task_id: task_id, duration: duration, error: failure.message, error_type: failure.type) + record(:task_failed, task_id: task_id, description: task.description, duration: duration, error: failure.message, error_type: failure.type) end, plan_completed: chain(hooks[:plan_completed]) do |plan_id:, status:, execution_time:, tasks:, results:| record(:plan_completed, plan_id: plan_id, status: status, execution_time: execution_time) @@ -91,7 +99,8 @@ def self.replay(path:) failed_task_ids: [], outputs: {}, failures: {}, - events: [] + events: [], + descriptions: {} ) return state unless File.exist?(path) @@ -102,6 +111,9 @@ def self.replay(path:) entry = JSON.parse(line, symbolize_names: true) state.events << entry + if entry[:task_id] && entry[:description] + state.descriptions[entry[:task_id]] = entry[:description] + end case entry[:event] when "task_succeeded" diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index d65b7d6..398f530 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -35,6 +35,7 @@ def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: } @concurrency_limit = concurrency_limit @async_tasks = {} + @task_agents = {} # Configure retry policy with defaults @retry_policy = { @@ -55,13 +56,21 @@ def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: end # Adds a task to the plan with optional dependencies + # + # Dependencies may be task ids or Task objects. An agent (anything + # responding to #execute) or a bare callable (receives the task, + # returns the output) can be attached directly, making a plan-wide + # agent provider optional. + # # @param task [Task] The task to add - # @param dependencies [Array] Array of task ids that this task depends on + # @param dependencies [Array] Tasks (or ids) this task depends on + # @param agent [#execute, #call, nil] The agent or callable to execute this task # @return [void] - def add_task(task, dependencies = []) + def add_task(task, dependencies = [], agent: nil) task_id = task.id @tasks[task_id] = task - @dependencies[task_id] = Array(dependencies) + @dependencies[task_id] = Array(dependencies).map { |dep| dep.respond_to?(:id) ? dep.id : dep } + @task_agents[task_id] = agent if agent @execution_state[:pending].add(task_id) end @@ -72,9 +81,16 @@ def add_task(task, dependencies = []) # current reactor instead of nesting a new event loop; standalone calls # still create their own reactor and block until the plan completes. # - # @param agent_provider [Object] An object that provides agents for task execution + # @param agent_provider [Object, nil] An object that provides agents for + # task execution (responds to #get_agent_for_task), or a callable + # factory (receives the task, returns an agent). Optional when every + # task was added with its own agent:, or when a block is given. + # @yield [task] Optional agent factory - called per task, returns an agent # @return [PlanExecutionResult] The structured execution results - def execute_plan(agent_provider) + def execute_plan(agent_provider = nil, &agent_factory) + agent_provider ||= agent_factory + ensure_agents_resolvable!(agent_provider) + @reactor = Sync do |reactor| @barrier = Async::Barrier.new @semaphore = Async::Semaphore.new(@concurrency_limit, parent: @barrier) @@ -262,6 +278,14 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) task = @tasks[task_id] transition_task_state(task_id, from: :pending, to: :in_progress) + # Pipe completed dependency outputs into the task before it runs + @dependencies[task_id].each do |dependency_id| + dependency_result = @results[dependency_id] + if dependency_result&.successful? + task.record_dependency_output(dependency_id, dependency_result.output) + end + end + # Call before_task_execution hook @lifecycle_hooks[:before_task_execution].call( task_id: task_id, @@ -279,7 +303,7 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) ) agent_build_start = Time.now - agent = agent_provider.get_agent_for_task(task) + agent = resolve_agent(task, agent_provider) agent_build_duration = Time.now - agent_build_start # Call after_agent_build hook @@ -442,6 +466,40 @@ def record_task_success(task_id, output) @results[task_id] = TaskExecutionResult.success(output) end + # Resolves the agent for a task: per-task agent first, then the + # plan-wide provider or factory + # @param task [Task] The task needing an agent + # @param agent_provider [Object, nil] Plan-wide provider or factory + # @return [Object] An agent responding to #execute + def resolve_agent(task, agent_provider) + per_task = @task_agents[task.id] + if per_task + # A per-task callable IS the work; wrap it so it receives the task + return per_task.respond_to?(:execute) ? per_task : CallableAgent.new(per_task, task) + end + + if agent_provider.respond_to?(:get_agent_for_task) + agent_provider.get_agent_for_task(task) + else + # A plan-wide callable is a factory: task in, agent out + agent_provider.call(task) + end + end + + # Fails fast when execute_plan is called with no way to obtain agents + # @param agent_provider [Object, nil] Plan-wide provider or factory + # @return [void] + def ensure_agents_resolvable!(agent_provider) + return if agent_provider + + missing = @tasks.keys.reject { |task_id| @task_agents.key?(task_id) } + return if missing.empty? + + raise ArgumentError, + "#{missing.size} task(s) have no agent. Pass an agent provider (or block) " \ + "to execute_plan, or add each task with add_task(task, agent: ...)" + end + # Records a task failure with proper state transition and result storage # @param task_id [String] ID of the failed task # @param failure [TaskFailure] The failure details diff --git a/lib/agentic/task.rb b/lib/agentic/task.rb index 3d188ff..f5ec298 100644 --- a/lib/agentic/task.rb +++ b/lib/agentic/task.rb @@ -21,13 +21,18 @@ class Task attr_reader :id, :description, :agent_spec, :input, :output, :status, :failure, :ready_to_execute attr_accessor :retry_count, :output_schema_name + # @return [Object, nil] Arbitrary domain object carried by the task, + # opaque to the framework - available to agents via the task itself + attr_accessor :payload + # Initializes a new task # @param description [String] Human-readable description of the task # @param agent_spec [Hash, AgentSpecification] Requirements for the agent that will execute this task # @param input [Hash] Input data for the task + # @param payload [Object, nil] Arbitrary domain data for the agent executing this task # @param output_schema_name [Symbol, nil] Name of the output schema to use for structured output # @return [Task] A new task instance - def initialize(description:, agent_spec:, input: {}, output_schema_name: nil) + def initialize(description:, agent_spec:, input: {}, payload: nil, output_schema_name: nil) @id = SecureRandom.uuid @description = description @@ -43,11 +48,13 @@ def initialize(description:, agent_spec:, input: {}, output_schema_name: nil) end @input = input + @payload = payload @output = nil @failure = nil @status = :pending @ready_to_execute = nil @output_schema_name = output_schema_name + @dependency_outputs = {} end # Creates a task from a TaskDefinition @@ -62,6 +69,26 @@ def self.from_definition(definition, input = {}) ) end + # Outputs of completed dependency tasks, keyed by task id. Populated + # by the orchestrator before this task executes. + # @return [Hash{String=>Object}] Dependency task id => output + attr_reader :dependency_outputs + + # Records the output of a completed dependency (called by the orchestrator) + # @param dependency_id [String] The dependency task's id + # @param output [Object] The dependency's output + # @return [void] + def record_dependency_output(dependency_id, output) + @dependency_outputs[dependency_id] = output + end + + # The output a dependency produced, looked up by task or id + # @param task_or_id [Task, String] The dependency task (or its id) + # @return [Object, nil] The dependency's output + def output_of(task_or_id) + @dependency_outputs[task_or_id.respond_to?(:id) ? task_or_id.id : task_or_id] + end + # Executes the task using the given agent # @param agent [Agent] The agent that will execute this task # @return [TaskResult] The result of the task execution diff --git a/lib/agentic/task_definition.rb b/lib/agentic/task_definition.rb index 3898bff..1d69b3f 100644 --- a/lib/agentic/task_definition.rb +++ b/lib/agentic/task_definition.rb @@ -17,6 +17,14 @@ def initialize(description:, agent:) @agent = agent end + # Builds an executable Task from this definition + # @param input [Hash] Input data for the task + # @param payload [Object, nil] Arbitrary domain data for the executing agent + # @return [Task] A new task ready for the orchestrator + def to_task(input: {}, payload: nil) + Task.new(description: description, agent_spec: agent, input: input, payload: payload) + end + # Returns a serializable representation of the task definition # @return [Hash] The task definition as a hash def to_h diff --git a/spec/agentic/capability_validator_spec.rb b/spec/agentic/capability_validator_spec.rb index 3740f8a..25cc25e 100644 --- a/spec/agentic/capability_validator_spec.rb +++ b/spec/agentic/capability_validator_spec.rb @@ -135,3 +135,38 @@ } end end + +RSpec.describe "composed capability contracts" do + let(:registry) { Agentic::AgentCapabilityRegistry.instance } + + before do + registry.clear + + shout_spec = Agentic::CapabilitySpecification.new( + name: "shout", description: "Upcases", version: "1.0.0", + inputs: {text: {type: "string", required: true}}, + outputs: {text: {type: "string", required: true}} + ) + registry.register(shout_spec, Agentic::CapabilityProvider.new( + capability: shout_spec, implementation: ->(i) { {text: i[:text].upcase} } + )) + end + + it "validates the composition's own declared contract at its boundary" do + registry.compose( + "greeting", "Shouted greeting", "1.0.0", + [{name: "shout", version: "1.0.0"}], + ->(providers, inputs) { {greeting: providers.first.execute(text: "hi #{inputs[:name]}")[:text]} }, + inputs: {name: {type: "string", required: true}}, + outputs: {greeting: {type: "string", required: true}} + ) + + provider = registry.get_provider("greeting") + + expect(provider.execute(name: "matz")).to eq(greeting: "HI MATZ") + expect { provider.execute({}) }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.capability).to eq("greeting") + expect(error.violations).to have_key(:name) + } + end +end diff --git a/spec/agentic/execution_journal_spec.rb b/spec/agentic/execution_journal_spec.rb index b3b3c8b..9f564c0 100644 --- a/spec/agentic/execution_journal_spec.rb +++ b/spec/agentic/execution_journal_spec.rb @@ -105,5 +105,30 @@ expect(state.failed_task_ids).to be_empty expect(state.failures).to be_empty end + + it "answers completed? by description, the cross-run idempotency key" do + journal.record(:task_succeeded, task_id: "run1-uuid", description: "invoice-3", output: nil) + + state = described_class.replay(path: path) + + expect(state.completed?("invoice-3")).to be true + expect(state.completed_descriptions).to eq(["invoice-3"]) + end + end + + describe "descriptions on lifecycle events" do + let(:agent) { double("Agent", execute: {"ok" => true}) } + let(:provider) { double("AgentProvider", get_agent_for_task: agent) } + + it "records the task description on success events so reruns can resume by name" do + orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) + task = Agentic::Task.new(description: "monthly-report", agent_spec: {"instructions" => "x"}, input: {}) + orchestrator.add_task(task) + orchestrator.execute_plan(provider) + + state = described_class.replay(path: path) + + expect(state.completed?("monthly-report")).to be true + end end end diff --git a/spec/agentic/plan_orchestrator_direct_agents_spec.rb b/spec/agentic/plan_orchestrator_direct_agents_spec.rb new file mode 100644 index 0000000..1a0ed3c --- /dev/null +++ b/spec/agentic/plan_orchestrator_direct_agents_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Agentic::PlanOrchestrator do + def task_named(description, payload: nil) + Agentic::Task.new( + description: description, + agent_spec: {"name" => "worker", "instructions" => "work"}, + input: {}, + payload: payload + ) + end + + describe "per-task agents" do + it "executes a task with a directly attached callable that receives the task" do + orchestrator = described_class.new + task = task_named("double it", payload: 21) + + orchestrator.add_task(task, agent: ->(t) { t.payload * 2 }) + result = orchestrator.execute_plan + + expect(result.status).to eq(:completed) + expect(result.results[task.id].output).to eq(42) + end + + it "executes a task with a directly attached agent object" do + agent = Class.new { + def execute(_prompt) = "from agent object" + }.new + orchestrator = described_class.new + task = task_named("run it") + + orchestrator.add_task(task, agent: agent) + result = orchestrator.execute_plan + + expect(result.results[task.id].output).to eq("from agent object") + end + + it "accepts a block as a plan-wide agent factory" do + agent = Class.new { + def execute(_prompt) = "built by factory" + }.new + orchestrator = described_class.new + task = task_named("factory task") + orchestrator.add_task(task) + + seen = nil + result = orchestrator.execute_plan do |t| + seen = t + agent + end + + expect(seen).to eq(task) + expect(result.results[task.id].output).to eq("built by factory") + end + + it "fails fast when no agent source exists" do + orchestrator = described_class.new + orchestrator.add_task(task_named("orphan")) + + expect { orchestrator.execute_plan }.to raise_error(ArgumentError, /no agent/i) + end + + it "prefers the per-task agent over the plan-wide provider" do + orchestrator = described_class.new + task = task_named("mine") + orchestrator.add_task(task, agent: ->(_t) { "per-task wins" }) + + result = orchestrator.execute_plan { |_t| raise "factory should not be consulted" } + + expect(result.results[task.id].output).to eq("per-task wins") + end + end + + describe "dependency output piping" do + it "pipes a dependency's output into the dependent task" do + orchestrator = described_class.new + fetch = task_named("fetch") + transform = task_named("transform") + + orchestrator.add_task(fetch, agent: ->(_t) { {"value" => 10} }) + orchestrator.add_task(transform, [fetch], agent: ->(t) { t.output_of(fetch)["value"] * 3 }) + + result = orchestrator.execute_plan + + expect(result.results[transform.id].output).to eq(30) + end + + it "fans in outputs from multiple dependencies" do + orchestrator = described_class.new + east = task_named("east") + west = task_named("west") + merge = task_named("merge") + + orchestrator.add_task(east, agent: ->(_t) { 1 }) + orchestrator.add_task(west, agent: ->(_t) { 2 }) + orchestrator.add_task(merge, [east, west], agent: ->(t) { + t.dependency_outputs.values.sum + }) + + result = orchestrator.execute_plan + + expect(result.results[merge.id].output).to eq(3) + end + + it "accepts Task objects as dependencies" do + orchestrator = described_class.new + first = task_named("first") + second = task_named("second") + order = [] + + orchestrator.add_task(first, agent: ->(_t) { order << :first }) + orchestrator.add_task(second, [first], agent: ->(_t) { order << :second }) + orchestrator.execute_plan + + expect(order).to eq([:first, :second]) + end + end +end + +RSpec.describe Agentic::TaskDefinition do + describe "#to_task" do + it "builds an executable task carrying input and payload" do + definition = described_class.new( + description: "Summarize", + agent: Agentic::AgentSpecification.new(name: "S", description: "d", instructions: "i") + ) + + task = definition.to_task(input: {topic: "ruby"}, payload: :domain_thing) + + expect(task).to be_a(Agentic::Task) + expect(task.description).to eq("Summarize") + expect(task.input).to eq(topic: "ruby") + expect(task.payload).to eq(:domain_thing) + end + end +end From 137f8af3b8b619ab625a23ea452d3b25b9eac8d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:09:00 +0000 Subject: [PATCH 25/46] docs: telephone game example (Matz round 3) A rumor garbles its way through five villagers via dependency output piping alone - the feature round 2 asked for, exercised with zero scaffolding. Field notes compare the two rounds line for line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/01-matz.md | 47 +++++++++++++++++++++++++++ examples/telephone_game.rb | 48 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/perspectives/round-3/01-matz.md create mode 100644 examples/telephone_game.rb diff --git a/docs/perspectives/round-3/01-matz.md b/docs/perspectives/round-3/01-matz.md new file mode 100644 index 0000000..e1299dd --- /dev/null +++ b/docs/perspectives/round-3/01-matz.md @@ -0,0 +1,47 @@ +# Round 3 field notes — Matz plays the telephone game + +*Built: `examples/telephone_game.rb` — a rumor passes through five +villagers, each hearing the previous version through the dependency +pipe and repeating it imperfectly.* + +## What I built and why + +In round 2 I asked for one thing: let dependent tasks *see* what their +dependencies produced. It exists now, so I built the program that is +nothing but that feature: the telephone game. Each villager's task +depends on the previous villager's, and the garbled rumor arrives via +`t.dependency_outputs` — no scroll smuggled through shared state, no +provider structs. The framework itself carries the whisper. + +"Old Tom saw a cat chase two mice" arrives at the town crier as +"HEAR YE: OLD TOM WRESTLED AN ENORMOUS CAT CHASE TWELVE WOLVES, DOWN BY +THE RIVER!!" — five hops, one millisecond, zero mutable globals. + +## Comparing my two rounds honestly + +My renga needed 110 lines and three pieces of scaffolding I resented: +the shared scroll, the `PoetAtTheTable` adapter, the `RengaProvider`. +The telephone game does *more* piping in ~50 lines and contains no +scaffolding at all — `add_task(task, [previous], agent: ->(t) { ... })` +is the entire wiring. This is what I mean when I say APIs should +disappear: the remaining code is all game, no framework. + +One line I especially enjoyed writing: +`heard = t.dependency_outputs.values.first || t.payload` — the first +villager has no dependency, so he reads the original rumor from his +payload. The nil case fell out naturally instead of needing a branch +somewhere else. When the empty case and the full case share a shape, +the design is right. + +## A small wish for round 4 + +`t.dependency_outputs.values.first` works but reads like plumbing. For +the extremely common one-dependency case, a `t.previous_output` (or +letting `output_of` default to the sole dependency) would make the line +sing. Grammar for the common case, hash access for the general one. + +## Verdict + +Round 2 I wrote a poem despite the framework; round 3 I wrote a joke +with it. Progress in a library is measured exactly there — in what you +stop noticing. diff --git a/examples/telephone_game.rb b/examples/telephone_game.rb new file mode 100644 index 0000000..550968a --- /dev/null +++ b/examples/telephone_game.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# The telephone game: a rumor passes through five villagers, each of +# whom hears the previous version through the orchestrator's dependency +# pipe and repeats it... imperfectly. No shared state, no scroll passed +# around - the framework itself carries the whisper. +# +# bundle exec ruby examples/telephone_game.rb ["a rumor"] +# +# Runs offline. The bug is the feature. + +require_relative "../lib/agentic" + +QUIRKS = { + "the miller" => ->(s) { s.sub(/\bsaw\b/, "wrestled") }, + "the baker" => ->(s) { s.sub(/\ba (\w+)/, 'an enormous \1') }, + "the fisherman" => ->(s) { "#{s.chomp(".")}, down by the river" }, + "the innkeeper" => ->(s) { s.gsub(/\btwo\b/i, "twelve").gsub(/\bmice\b/, "wolves") }, + "the town crier" => ->(s) { "HEAR YE: #{s.upcase}!!" } +}.freeze + +rumor = ARGV.first || "Old Tom saw a cat chase two mice." + +orchestrator = Agentic::PlanOrchestrator.new +tasks = [] +QUIRKS.each_key do |villager| + task = Agentic::Task.new( + description: villager, + agent_spec: {"name" => villager, "instructions" => "Repeat what you heard"}, + payload: rumor + ) + orchestrator.add_task(task, tasks.empty? ? [] : [tasks.last], agent: ->(t) { + heard = t.dependency_outputs.values.first || t.payload + QUIRKS.fetch(t.description).call(heard) + }) + tasks << task +end + +result = orchestrator.execute_plan + +puts "the rumor: \"#{rumor}\"" +puts +tasks.each do |task| + puts format("%-14s heard it as: %s", task.description, result.results[task.id].output) +end +puts +puts "(#{result.status} in #{(result.execution_time * 1000).round}ms - " \ + "the whisper traveled through #{tasks.size} villagers)" From 2dcf878e026abd3ce917512b1483e4006a961e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:10:37 +0000 Subject: [PATCH 26/46] docs: standup digest example (DHH round 3) Three parallel repo collectors fan into one writer task through dependency output piping - real git/TODO/spec data, no adapters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/02-dhh.md | 52 ++++++++++++++++++++ examples/standup_digest.rb | 75 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 docs/perspectives/round-3/02-dhh.md create mode 100644 examples/standup_digest.rb diff --git a/docs/perspectives/round-3/02-dhh.md b/docs/perspectives/round-3/02-dhh.md new file mode 100644 index 0000000..507f3b3 --- /dev/null +++ b/docs/perspectives/round-3/02-dhh.md @@ -0,0 +1,52 @@ +# Round 3 field notes — DHH cancels the standup + +*Built: `examples/standup_digest.rb` — three collectors read the repo in +parallel, one writer fans their outputs in and publishes the digest.* + +## What I built and why + +The asynchronous standup is the calm-company move: nobody talks, the +repo speaks. Three collectors run in parallel — recent commits grouped +by theme, TODO/FIXME debt in `lib/`, the size of the safety net — and a +writer task that depends on all three composes the digest: + +``` +shipped: 12 recent commits (11 docs, 1 feat) +owed: 0 TODO/FIXME/HACK markers in lib/ (clean!) +guarded by: 530 examples across 57 spec files +``` + +Real data, real repo, 26ms. And the shape is the point: **fan-in**. The +writer declares `[commits, debt, tests]` as dependencies and reads +`t.output_of(commits)` for each. In round 2 this exact shape would have +required a shared hash and a provider struct; now it's the framework's +native grammar. This is what I meant by compression — the concept count +in my program dropped to the concept count of my *idea*. + +## What building it felt like this time + +- `add_task(task, [commits, debt, tests], agent: ->(t) { ... })` — + passing actual Task objects as dependencies instead of `.id` strings + is a small mercy that removes a whole category of typo. +- `payload` killed the "look it up by description" caveman move from my + ticket screener. Nothing in this program is keyed by string except + things that are actually strings. +- The collectors shelling to `git log` felt right, not hacky: the + framework doesn't care whether an agent is an LLM, a lambda, or a + subprocess. That agnosticism is worth protecting. + +## Remaining gripe, downgraded from complaint to suggestion + +The writer reads three outputs with three `t.output_of(...)` calls. +Fine. But notice the asymmetry: dependencies are declared in one place +and consumed in another, connected by nothing but my discipline. The +Rails move would be naming them: +`add_task(digest, needs: {shipped: commits, owed: debt}, ...)` then +`t.needs.shipped` in the agent. Declared and consumed under one name. +File under round 4. + +## Verdict + +Round 2 I shipped a screener despite thirty lines of adapter; round 3 I +shipped a standup-killer in zero. The roadmap wasn't advisory — it was +the product backlog, and it shipped. diff --git a/examples/standup_digest.rb b/examples/standup_digest.rb new file mode 100644 index 0000000..51d145d --- /dev/null +++ b/examples/standup_digest.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# The Standup Digest: three collectors gather from the repo in +# parallel - recent commits, TODO debt, test suite shape - and a writer +# task fans their outputs in through the dependency pipe and publishes +# the digest nobody has to attend a meeting for. +# +# bundle exec ruby examples/standup_digest.rb +# +# Runs offline against the current git repo. The meeting is cancelled. + +require_relative "../lib/agentic" + +ROOT = File.expand_path("..", __dir__) + +def repo_task(description, payload = nil) + Agentic::Task.new( + description: description, + agent_spec: {"name" => description, "instructions" => "Collect facts"}, + payload: payload + ) +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) + +commits = repo_task("recent commits") +orchestrator.add_task(commits, agent: ->(_t) { + log = `git -C #{ROOT} log --oneline -12 --pretty=format:"%s"`.lines.map(&:strip) + themes = log.group_by { |line| line[/\A(\w+)(?:\(|:)/, 1] || "misc" } + {count: log.size, themes: themes.transform_values(&:size), latest: log.first} +}) + +debt = repo_task("todo debt") +orchestrator.add_task(debt, agent: ->(_t) { + hits = Dir[File.join(ROOT, "lib", "**", "*.rb")].flat_map { |path| + File.readlines(path, encoding: "UTF-8").each_with_index.select { |line, _| line =~ /#.*(TODO|FIXME|HACK)/ } + .map { |line, i| "#{path.delete_prefix("#{ROOT}/")}:#{i + 1} #{line.strip.sub(/\A#\s*/, "")}" } + } + {count: hits.size, items: hits.first(5)} +}) + +tests = repo_task("test suite shape") +orchestrator.add_task(tests, agent: ->(_t) { + spec_files = Dir[File.join(ROOT, "spec", "**", "*_spec.rb")] + examples = spec_files.sum { |f| File.read(f, encoding: "UTF-8").scan(/^\s*it\s/).size } + {files: spec_files.size, examples: examples} +}) + +digest = repo_task("digest") +orchestrator.add_task(digest, [commits, debt, tests], agent: ->(t) { + shipped = t.output_of(commits) + owed = t.output_of(debt) + suite = t.output_of(tests) + + lines = [] + lines << "STANDUP DIGEST" + lines << "" + lines << "shipped: #{shipped[:count]} recent commits " \ + "(#{shipped[:themes].map { |k, v| "#{v} #{k}" }.join(", ")})" + lines << " latest: #{shipped[:latest]}" + lines << "" + lines << "owed: #{owed[:count]} TODO/FIXME/HACK markers in lib/" + owed[:items].each { |item| lines << " - #{item}" } + lines << " (clean!)" if owed[:count].zero? + lines << "" + lines << "guarded by: #{suite[:examples]} examples across #{suite[:files]} spec files" + lines.join("\n") +}) + +result = orchestrator.execute_plan + +puts result.results[digest.id].output +puts +puts "(three collectors in parallel + one writer, #{result.status} " \ + "in #{(result.execution_time * 1000).round}ms)" From 340697ae1bfbcee93843789f714e714edefec784 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:18:34 +0000 Subject: [PATCH 27/46] fix: scheduler deadlock at tight concurrency limits; add plan Gantt example schedule_dependent_tasks ran inside the completing task's semaphore slot, and spawning a dependent via semaphore.async blocks when the semaphore is full - so two slot-holders finishing together at a tight limit deadlocked waiting for each other's slots. Tasks now spawn through the barrier and acquire the semaphore inside the spawned fiber; the slot body is extracted into execute_task_in_slot. Regression spec covers the diamond-at-limit-2 case. Found by examples/plan_gantt.rb (tenderlove round 3), which renders a plan's execution as an ASCII timeline from lifecycle hooks - including queue-wait time, which is how the deadlock surfaced. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/03-tenderlove.md | 60 +++++++ examples/plan_gantt.rb | 73 ++++++++ lib/agentic/plan_orchestrator.rb | 160 ++++++++++-------- .../plan_orchestrator_direct_agents_spec.rb | 22 +++ 4 files changed, 244 insertions(+), 71 deletions(-) create mode 100644 docs/perspectives/round-3/03-tenderlove.md create mode 100644 examples/plan_gantt.rb diff --git a/docs/perspectives/round-3/03-tenderlove.md b/docs/perspectives/round-3/03-tenderlove.md new file mode 100644 index 0000000..abc1b61 --- /dev/null +++ b/docs/perspectives/round-3/03-tenderlove.md @@ -0,0 +1,60 @@ +# Round 3 field notes — Aaron Patterson draws the Plan Gantt + +*Built: `examples/plan_gantt.rb` — lifecycle hooks timestamp every task; +the run renders as an ASCII timeline. Found and fixed a scheduler +deadlock before the chart drew its first bar.* + +## What I built and why + +You can't reason about a scheduler you can't see. So: a six-task diamond +(three fetches → two joins → one report) with simulated IO, hooks +recording start/finish, and a Gantt renderer: + +``` +fetch:users |############ | 0-121ms +fetch:orders |#################### | 0-200ms +fetch:events |#################### | 0-201ms +join:revenue | ############### | 200-351ms +join:activity | ########## | 201-301ms +report:weekly | ###### | 351-411ms +serial floor 710ms -> actual 412ms (1.7x from the scheduler) +``` + +## The part where the chart never rendered + +First run: nothing. Not slow — **hung**. The diamond at +`concurrency_limit: 2` deadlocked the orchestrator, every time. + +The autopsy: `schedule_dependent_tasks` ran *inside the completing +task's semaphore slot*, and scheduling a dependent called +`semaphore.async` — which blocks when the semaphore is full. So when +both slot-holders finished around the same moment and each tried to +spawn its dependents, each blocked waiting for a slot that could only be +freed by... the other blocked holder. A textbook hold-and-wait, shipped +since the orchestrator was written, invisible because nothing before +this chart combined fan-in dependencies with a tight limit. My renga +(chain, limit 10) sailed past it; Samuel's latency lab (no deps) sailed +past it; the diamond at limit 2 hit it in one millisecond. + +The fix is the structured-concurrency idiom: spawn through the +**barrier** (non-blocking), acquire the semaphore **inside** the spawned +fiber. Slot-holders never block on spawning; waiters queue in their own +fibers. Ninety lines of `schedule_task` also got a long-overdue +extraction into `execute_task_in_slot` — Sandi's dojo had already put +that method on the suspects board, so consider this a twofer. Regression +spec included: diamond, tight limit, five-second timeout, must complete. + +## A subtlety the chart makes visible + +`fetch:events` shows 0–201ms but only *ran* for 80ms — the bar includes +121ms queued waiting for a slot, because `before_task_execution` fires +at schedule time, not slot-acquisition time. I left it: queue time IS +where your latency went, and a chart that hides saturation is a chart +that lies. But the hooks should probably grow a `task_slot_acquired` +event so tools can split wait from work. + +## Verdict + +Wrote a visualization, got a deadlock fix, a method extraction, and a +regression test. Observability tools pay for themselves before they're +finished — that's why you build them first, not after the incident. diff --git a/examples/plan_gantt.rb b/examples/plan_gantt.rb new file mode 100644 index 0000000..9808fe1 --- /dev/null +++ b/examples/plan_gantt.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# The Plan Gantt: lifecycle hooks timestamp every task, then the run is +# rendered as an ASCII timeline - where your wall clock actually went. +# A diamond dependency graph with a tight concurrency limit makes the +# scheduler's decisions visible to the naked eye. +# +# bundle exec ruby examples/plan_gantt.rb [concurrency] +# +# Runs offline; task durations are simulated IO. + +require_relative "../lib/agentic" + +WORK = { + "fetch:users" => {sleep: 0.12, deps: []}, + "fetch:orders" => {sleep: 0.20, deps: []}, + "fetch:events" => {sleep: 0.08, deps: []}, + "join:activity" => {sleep: 0.10, deps: ["fetch:users", "fetch:events"]}, + "join:revenue" => {sleep: 0.15, deps: ["fetch:users", "fetch:orders"]}, + "report:weekly" => {sleep: 0.06, deps: ["join:activity", "join:revenue"]} +}.freeze + +concurrency = (ARGV.first || 2).to_i +timeline = {} +plan_start = nil + +hooks = { + before_task_execution: ->(task_id:, task:) { + plan_start ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeline[task.description] ||= {})[:start] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start + }, + after_task_success: ->(task_id:, task:, result:, duration:) { + timeline[task.description][:finish] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start + } +} + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency, lifecycle_hooks: hooks) +tasks = {} +WORK.each do |name, spec| + task = Agentic::Task.new( + description: name, + agent_spec: {"name" => name, "instructions" => "simulate"}, + payload: spec[:sleep] + ) + tasks[name] = task + orchestrator.add_task(task, spec[:deps].map { |d| tasks.fetch(d) }, agent: ->(t) { + sleep(t.payload) + :done + }) +end + +result = orchestrator.execute_plan + +# Render: 1 column = 10ms +total = timeline.values.map { |t| t[:finish] }.max +columns = (total * 100).ceil +puts "PLAN GANTT (concurrency #{concurrency}, #{(result.execution_time * 1000).round}ms wall)" +puts +timeline.each do |name, t| + from = (t[:start] * 100).round + width = [((t[:finish] - t[:start]) * 100).round, 1].max + bar = ((" " * from) + ("#" * width)).ljust(columns) + puts format(" %-16s |%s| %3d-%3dms", name, bar, t[:start] * 1000, t[:finish] * 1000) +end +puts +puts format(" %-16s |%s|", "", (0..columns).step(10).map { |c| (c / 10).to_s.ljust(10) }.join[0, columns + 1]) +puts " (one column = 10ms; numbers along the base are x100ms)" +puts +serial_floor = WORK.values.sum { |w| w[:sleep] } +puts format(" serial floor %.0fms -> actual %.0fms (%.1fx from the scheduler)", + serial_floor * 1000, result.execution_time * 1000, serial_floor / result.execution_time) diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index 398f530..ff94f31 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -292,77 +292,15 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) task: task ) - # Schedule task execution with the semaphore - async_task = semaphore.async do - task_start_time = Time.now - begin - # Call before_agent_build hook - @lifecycle_hooks[:before_agent_build].call( - task_id: task_id, - task: task - ) - - agent_build_start = Time.now - agent = resolve_agent(task, agent_provider) - agent_build_duration = Time.now - agent_build_start - - # Call after_agent_build hook - @lifecycle_hooks[:after_agent_build].call( - task_id: task_id, - task: task, - agent: agent, - build_duration: agent_build_duration - ) - - result = task.perform(agent) - task_duration = Time.now - task_start_time - - # Record result and update state - if result.successful? - record_task_success(task_id, result.output) - - # Call after_task_success hook - @lifecycle_hooks[:after_task_success].call( - task_id: task_id, - task: task, - result: result, - duration: task_duration - ) - - # Find and schedule dependent tasks - schedule_dependent_tasks(task_id, agent_provider, semaphore, barrier) - else - record_task_failure(task_id, result.failure) - - # Call after_task_failure hook - @lifecycle_hooks[:after_task_failure].call( - task_id: task_id, - task: task, - failure: result.failure, - duration: task_duration - ) - - # Handle failure based on policy - handle_task_failure(task, result.failure, agent_provider, semaphore, barrier) - end - rescue => e - # Handle unexpected errors - failure = TaskFailure.from_exception(e, { - task_id: task_id, - context_type: "unexpected_error" - }) - - record_task_failure(task_id, failure) - - # Call after_task_failure hook for unexpected errors - @lifecycle_hooks[:after_task_failure].call( - task_id: task_id, - task: task, - failure: failure, - duration: Time.now - task_start_time - ) - - Agentic.logger.error("Unexpected error in task #{task_id}: #{e.message}") + # Spawn through the barrier and acquire the semaphore INSIDE the + # spawned fiber. Spawning with semaphore.async here would block the + # caller when the semaphore is full - and completing tasks schedule + # their dependents from within their own slot, so two slot-holders + # spawning dependents at a tight concurrency limit would deadlock + # waiting for each other's slots. + async_task = barrier.async do + semaphore.acquire do + execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) end end @@ -370,6 +308,86 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) @async_tasks[task_id] = async_task end + # Runs one task inside an acquired concurrency slot: builds the agent, + # performs the task, records the outcome, and fans out to dependents + # @param task_id [String] ID of the task + # @param task [Task] The task to run + # @param agent_provider [Object, nil] Provides agents for task execution + # @param semaphore [Async::Semaphore] Controls concurrency + # @param barrier [Async::Barrier] Tracks task completion + # @return [void] + def execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) + task_start_time = Time.now + + # Call before_agent_build hook + @lifecycle_hooks[:before_agent_build].call( + task_id: task_id, + task: task + ) + + agent_build_start = Time.now + agent = resolve_agent(task, agent_provider) + agent_build_duration = Time.now - agent_build_start + + # Call after_agent_build hook + @lifecycle_hooks[:after_agent_build].call( + task_id: task_id, + task: task, + agent: agent, + build_duration: agent_build_duration + ) + + result = task.perform(agent) + task_duration = Time.now - task_start_time + + # Record result and update state + if result.successful? + record_task_success(task_id, result.output) + + # Call after_task_success hook + @lifecycle_hooks[:after_task_success].call( + task_id: task_id, + task: task, + result: result, + duration: task_duration + ) + + # Find and schedule dependent tasks + schedule_dependent_tasks(task_id, agent_provider, semaphore, barrier) + else + record_task_failure(task_id, result.failure) + + # Call after_task_failure hook + @lifecycle_hooks[:after_task_failure].call( + task_id: task_id, + task: task, + failure: result.failure, + duration: task_duration + ) + + # Handle failure based on policy + handle_task_failure(task, result.failure, agent_provider, semaphore, barrier) + end + rescue => e + # Handle unexpected errors + failure = TaskFailure.from_exception(e, { + task_id: task_id, + context_type: "unexpected_error" + }) + + record_task_failure(task_id, failure) + + # Call after_task_failure hook for unexpected errors + @lifecycle_hooks[:after_task_failure].call( + task_id: task_id, + task: task, + failure: failure, + duration: Time.now - task_start_time + ) + + Agentic.logger.error("Unexpected error in task #{task_id}: #{e.message}") + end + # Schedules tasks that depend on a completed task # @param completed_task_id [String] ID of the completed task # @param agent_provider [Object] Provides agents for task execution diff --git a/spec/agentic/plan_orchestrator_direct_agents_spec.rb b/spec/agentic/plan_orchestrator_direct_agents_spec.rb index 1a0ed3c..9fc3d10 100644 --- a/spec/agentic/plan_orchestrator_direct_agents_spec.rb +++ b/spec/agentic/plan_orchestrator_direct_agents_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "timeout" RSpec.describe Agentic::PlanOrchestrator do def task_named(description, payload: nil) @@ -104,6 +105,27 @@ def execute(_prompt) = "built by factory" expect(result.results[merge.id].output).to eq(3) end + it "does not deadlock when slot-holders schedule dependents at a tight concurrency limit" do + # Regression: a diamond graph at concurrency 2 used to deadlock when + # both slot-holding tasks finished and each blocked spawning its + # dependents while waiting for the other's slot + orchestrator = described_class.new(concurrency_limit: 2) + sources = 3.times.map { |i| task_named("source-#{i}") } + joins = 2.times.map { |i| task_named("join-#{i}") } + final = task_named("final") + + sources.each { |t| orchestrator.add_task(t, agent: ->(_t) { sleep(0.01) || :ok }) } + orchestrator.add_task(joins[0], sources.first(2), agent: ->(_t) { sleep(0.01) || :ok }) + orchestrator.add_task(joins[1], sources.last(2), agent: ->(_t) { sleep(0.01) || :ok }) + orchestrator.add_task(final, joins, agent: ->(_t) { :done }) + + result = nil + Timeout.timeout(5) { result = orchestrator.execute_plan } + + expect(result.status).to eq(:completed) + expect(result.results[final.id].output).to eq(:done) + end + it "accepts Task objects as dependencies" do orchestrator = described_class.new first = task_named("first") From fd09a9199cae8a40fdf6d5c57b63fdd8ec154a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:20:35 +0000 Subject: [PATCH 28/46] docs: documentation coverage surveyor example (fxn round 3) Per-file YARD coverage surveys fan into a single report task via dependency piping; 90.2% coverage measured, with the Thor-desc blind spot documented. Field notes on order-sensitive Prism traversal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/04-fxn.md | 54 +++++++++++++++ examples/doc_coverage.rb | 100 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/perspectives/round-3/04-fxn.md create mode 100644 examples/doc_coverage.rb diff --git a/docs/perspectives/round-3/04-fxn.md b/docs/perspectives/round-3/04-fxn.md new file mode 100644 index 0000000..f9a7c6d --- /dev/null +++ b/docs/perspectives/round-3/04-fxn.md @@ -0,0 +1,54 @@ +# Round 3 field notes — Xavier Noria surveys the documentation + +*Built: `examples/doc_coverage.rb` — YARD comment coverage for every +public method, one survey task per file, one report task fanning all +surveys in through the dependency pipe.* + +## What I built and why + +Last round I mapped constants; this round I measured what the gem +*says about itself*. Prism supplies both the definitions and the +comments (`parsed.comments` with locations — the parser hands you the +prose as data), so coverage is a set intersection: a public `def` whose +preceding line is a comment is documented. Private methods are exempt — +documentation is for the public boundary; a `private` marker is itself +documentation of a different kind. + +The verdict on this gem: **322/357 public methods documented (90.2%)**, +which is genuinely high, and the undocumented residue is concentrated +exactly where you'd guess — the Thor CLI classes, at 0%. + +## The nuance worth writing down + +The CLI's 0% is partly a measurement artifact with a real lesson in it. +Thor commands are documented with `desc "list", "List available +agents"` — *runtime* documentation the survey doesn't count, because it +isn't a comment. Two documentation systems, one for the human at the +terminal and one for the human in the editor, and a file can be perfect +in one and invisible to the other. A tool that reports "0%" without +this caveat would be lying with statistics. Conformance tools must +document their own blind spots — that's the same lesson as round 2's +`version.rb` false positive, generalized. + +## Building on the improved framework + +- The fan-in report is the new API earning its keep in a shape my + round-2 cartographer couldn't express: one task depending on **64** + others, reading each survey with `t.output_of(s)`. No shared chart + hash, no expedition struct. The aggregation step is now *part of the + plan* rather than code after it — which matters, because it means the + report could itself have dependents. +- `payload:` carries the file path; `description` is now free to be a + human label rather than a smuggling route. Small change, but every + string in the program means what it says again. +- Prism note for fellow travelers: tracking `private` visibility means + walking `StatementsNode` children *in order* with carried state — a + fold, not a map. My first draft treated it as a recursive map and + quietly surveyed private methods. Order matters in class bodies; ask + your traversal to respect it. + +## Verdict + +The gem documented itself at 90% and the framework expressed +"survey everything, then summarize" as a single dependency graph. Both +facts would have taken more code to establish a week ago. diff --git a/examples/doc_coverage.rb b/examples/doc_coverage.rb new file mode 100644 index 0000000..f37b87b --- /dev/null +++ b/examples/doc_coverage.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# The Documentation Surveyor: measures YARD comment coverage for every +# public method in a lib/ tree. One survey task per file fans out; a +# single report task fans all the surveys in through the dependency +# pipe and renders the coverage table. +# +# bundle exec ruby examples/doc_coverage.rb [lib_dir] +# +# Runs offline; Prism reads the definitions, the comments speak for +# themselves. + +require_relative "../lib/agentic" +require "prism" + +LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") + +# Walks a parse tree counting public defs and whether a comment +# immediately precedes each one +def survey(parsed) + comment_lines = parsed.comments.map { |c| c.location.start_line }.to_set + stats = {documented: 0, undocumented: [], private_from: nil} + + # Track `private` markers statement-by-statement within class bodies + walk = lambda do |node, private_scope| + return unless node + + if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) + inner = false + node.child_nodes.each { |child| inner = walk.call(child, inner) || inner } + private_scope + elsif node.is_a?(Prism::StatementsNode) + scope = private_scope + node.child_nodes.each { |child| scope = walk.call(child, scope) || scope } + scope + elsif node.is_a?(Prism::CallNode) && node.name == :private && node.receiver.nil? && node.arguments.nil? + true + elsif node.is_a?(Prism::DefNode) + unless private_scope + if comment_lines.include?(node.location.start_line - 1) + stats[:documented] += 1 + else + stats[:undocumented] << {name: node.name.to_s, line: node.location.start_line} + end + end + false + else + node.child_nodes.each { |child| walk.call(child, private_scope) } + false + end + end + + walk.call(parsed.value, false) + stats +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) +files = Dir[File.join(LIB, "**", "*.rb")].sort + +surveys = files.map do |path| + task = Agentic::Task.new( + description: path.delete_prefix("#{LIB}/"), + agent_spec: {"name" => "Surveyor", "instructions" => "Survey documentation"}, + payload: path + ) + orchestrator.add_task(task, agent: ->(t) { survey(Prism.parse_file(t.payload)) }) + task +end + +report = Agentic::Task.new( + description: "coverage report", + agent_spec: {"name" => "Reporter", "instructions" => "Aggregate"} +) +orchestrator.add_task(report, surveys, agent: ->(t) { + rows = surveys.map { |s| + stats = t.output_of(s) + total = stats[:documented] + stats[:undocumented].size + {file: s.description, documented: stats[:documented], total: total, + missing: stats[:undocumented]} + } + covered = rows.sum { |r| r[:documented] } + total = rows.sum { |r| r[:total] } + {rows: rows, covered: covered, total: total} +}) + +result = orchestrator.execute_plan +data = result.results[report.id].output + +puts "DOCUMENTATION SURVEY of #{LIB}" +puts format(" %d/%d public methods documented (%.1f%%)", + data[:covered], data[:total], 100.0 * data[:covered] / data[:total]) +puts +worst = data[:rows].select { |r| r[:total] > 0 } + .sort_by { |r| [Float(r[:documented]) / r[:total], -r[:total]] }.first(5) +puts "least documented files:" +worst.each do |row| + puts format(" %5.1f%% %-46s (%d/%d)", + 100.0 * row[:documented] / row[:total], row[:file], row[:documented], row[:total]) + row[:missing].first(2).each { |m| puts " missing: ##{m[:name]} (line #{m[:line]})" } +end From 223ed024456ff61e96db36580b3f84fc40b6e0db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:22:33 +0000 Subject: [PATCH 29/46] docs: live dashboard example (ioquatix round 3) Lifecycle hooks feed an Async::Queue consumed by a renderer task in the same reactor - the promised streaming observability built from existing parts. Field notes document the hooks-run-inline contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/05-ioquatix.md | 59 +++++++++++++++++ examples/live_dashboard.rb | 80 ++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/perspectives/round-3/05-ioquatix.md create mode 100644 examples/live_dashboard.rb diff --git a/docs/perspectives/round-3/05-ioquatix.md b/docs/perspectives/round-3/05-ioquatix.md new file mode 100644 index 0000000..581e363 --- /dev/null +++ b/docs/perspectives/round-3/05-ioquatix.md @@ -0,0 +1,59 @@ +# Round 3 field notes — Samuel Williams streams the plan live + +*Built: `examples/live_dashboard.rb` — lifecycle hooks publish onto an +`Async::Queue`; a renderer task in the same reactor draws the plan's +state while it runs.* + +## What I built and why + +The architecture documents have promised a `StreamingObservabilityHub` +since before round 1. I built the load-bearing part of it in thirty +structural lines, from parts already in the box: hooks enqueue events, +an `Async::Queue` carries them, and a sibling task dequeues and renders +— *while the plan executes*, in the same reactor, with timestamps to +prove it: + +``` + 0ms > running resize:thumbnails + 151ms + done resize:thumbnails (ran 151ms) + 151ms > running extract:captions + ... + 423ms = plan completed in 422ms +``` + +Every line printed live. No hub, no subscriber registry, no thread — a +queue and two fibers. The renderer's `dequeue` suspends when the queue +is empty and wakes when a hook enqueues; back-pressure and ordering come +free with the data structure. This is my standing argument about +observability systems: **the event stream is a queue, so use a queue.** +The remaining "hub" work is multiplexing to N consumers, which is +`Async::Queue` per subscriber and a fan-out loop — an afternoon. + +## What this build depended on, specifically + +- Aaron's deadlock fix from two days ago is the reason this demo is + honest: plan + renderer at `concurrency_limit: 2` is exactly the + saturated-reactor shape that used to hang. I ran it before writing + these notes; it didn't. Structured concurrency bugs die when someone + builds the tool that would witness them. +- The composition contract from my own round-1 fix carries the whole + design: `orchestrator.execute_plan` inside `Sync` joins the reactor, + so `renderer.wait` after it is ordinary structured concurrency — + spawn, do work, join. If the orchestrator still seized its own event + loop, this program would be two processes and a pipe. + +## One design observation for the maintainers + +Hooks fire *inline* in the task fiber, so a slow hook slows the plan — +today's hooks-to-queue pattern is safe precisely because `enqueue` is +O(1) and non-blocking. That property should be in the hooks' +documentation as a contract: "your hook runs on the task's critical +path; hand off anything slower than a hash insert." The dashboard is +both the demo and the recommended escape hatch. + +## Verdict + +The promised streaming layer turned out to be one queue away. Ship this +pattern in the docs, mark the hub as "compose it yourself from these +parts," and the architecture document loses its last piece of +fiction. diff --git a/examples/live_dashboard.rb b/examples/live_dashboard.rb new file mode 100644 index 0000000..d6c61e1 --- /dev/null +++ b/examples/live_dashboard.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# The Live Dashboard: lifecycle hooks publish events onto an +# Async::Queue; a consumer task IN THE SAME REACTOR renders the plan's +# state as it changes. This is the "streaming observability" the +# architecture documents promise, built from a queue and the hooks +# that already exist - about thirty structural lines. +# +# bundle exec ruby examples/live_dashboard.rb +# +# Runs offline; watch the states flip while the plan executes. + +require_relative "../lib/agentic" +require "async/queue" + +WORK = { + "resize:thumbnails" => {sleep: 0.15, deps: []}, + "transcode:video" => {sleep: 0.30, deps: []}, + "extract:captions" => {sleep: 0.10, deps: []}, + "compose:preview" => {sleep: 0.12, deps: ["resize:thumbnails", "extract:captions"]}, + "publish:episode" => {sleep: 0.05, deps: ["compose:preview", "transcode:video"]} +}.freeze + +events = Async::Queue.new + +hooks = { + before_task_execution: ->(task_id:, task:) { events.enqueue([:queued, task.description]) }, + after_agent_build: ->(task_id:, task:, agent:, build_duration:) { events.enqueue([:running, task.description]) }, + after_task_success: ->(task_id:, task:, result:, duration:) { + events.enqueue([:done, task.description, duration]) + }, + plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) { + events.enqueue([:plan_done, status, execution_time]) + } +} + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: hooks) +tasks = {} +WORK.each do |name, spec| + task = Agentic::Task.new( + description: name, + agent_spec: {"name" => name, "instructions" => "process"}, + payload: spec[:sleep] + ) + tasks[name] = task + orchestrator.add_task(task, spec[:deps].map { |d| tasks.fetch(d) }, agent: ->(t) { + sleep(t.payload) + :ok + }) +end + +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +stamp = -> { format("%5dms", (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000) } + +puts "LIVE DASHBOARD (plan and renderer sharing one reactor, concurrency 2)" +puts + +Sync do |host| + # The renderer: a sibling task consuming the event stream live + renderer = host.async do + loop do + event = events.dequeue + case event.first + when :queued then puts "#{stamp.call} ~ queued #{event[1]}" + when :running then puts "#{stamp.call} > running #{event[1]}" + when :done then puts format("%s + done %-20s (ran %dms)", stamp.call, event[1], event[2] * 1000) + when :plan_done + puts format("%s = plan %s in %dms", stamp.call, event[1], event[2] * 1000) + break + end + end + end + + orchestrator.execute_plan + renderer.wait +end + +puts +puts "every line above was printed WHILE the plan ran - the hooks are a" +puts "live event stream, not a post-mortem log" From 6063b85ee0dca3ba4726a55f39bbdb044e5c866a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:23:48 +0000 Subject: [PATCH 30/46] docs: contract fuzzer example (Jeremy Evans round 3) Seed-deterministic bidirectional fuzz of every registered capability contract: conforming inputs must pass, dropped-required and type-corrupted inputs must fail. 34 trials, boundary holds, CI-ready exit code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/06-jeremyevans.md | 56 +++++++++++ examples/contract_fuzzer.rb | 103 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 docs/perspectives/round-3/06-jeremyevans.md create mode 100644 examples/contract_fuzzer.rb diff --git a/docs/perspectives/round-3/06-jeremyevans.md b/docs/perspectives/round-3/06-jeremyevans.md new file mode 100644 index 0000000..86658e1 --- /dev/null +++ b/docs/perspectives/round-3/06-jeremyevans.md @@ -0,0 +1,56 @@ +# Round 3 field notes — Jeremy Evans fuzzes the boundary + +*Built: `examples/contract_fuzzer.rb` — for every registered +capability, generate inputs that should pass and mutations that should +fail, and verify the validator agrees. Deterministic by seed.* + +## What I built and why + +A validator is a claim: "conforming data passes, violating data does +not." Claims get tested. The fuzzer walks every registered capability's +declared contract and runs three trial families against it: + +1. **Conforming inputs must pass** — generated per declared type. +2. **Each required key, dropped, must fail.** +3. **Each typed key, corrupted, must fail** — a number where a string + was promised, a string where an array was. + +Seven standard capabilities, 34 trials, and the verdict I wanted to be +able to print: *the boundary holds*. Both directions matter equally — +a validator that rejects good data breaks working programs, one that +accepts bad data breaks the programs downstream, and only a +bidirectional fuzz distinguishes "strict" from "correct." + +## Determinism is the feature + +`Random.new(seed)` and every random choice drawn from it, with the seed +printed in the header and settable from ARGV. A fuzzer that can't +reproduce its own failure is a rumor generator. Run it twice, same +verdicts; file a bug with the seed, get the same failure on my machine. +This costs one line and I will die on this hill: **all** randomized +testing should work this way. (I also deliberately fuzz the *validator*, +not `provider.execute` — one of the standard capabilities talks to the +network when executed, and a fuzzer with side effects is a chaos +monkey, which is a different tool with a different consent form.) + +## What the exercise says about the framework + +- solnic's `CapabilityValidator` passed a test it wasn't written + against. That's what "the types are load-bearing" means in practice — + the declarations in `CapabilitySpecification` were precise enough for + a third party to mechanically derive both the passing and the failing + cases. Vague contracts can't be fuzzed; these could. +- The fuzzer found no defects *today*. Its value is the exit code: wire + it into CI and the next person who adds a capability with a mistyped + contract gets a named trial failure, not a production surprise. Cheap + insurance is the best kind. +- Gap worth recording: contracts can't yet express constraints beyond + type and presence — no ranges, no enums, no "non-empty array". The + fuzzer therefore can't test what can't be said. When contracts grow + expressiveness, this file is where their honesty gets checked. + +## Verdict + +Thirty-four trials, zero defects, one exit code CI can trust, and a +reproducibility guarantee. Boring, deterministic, adversarial — the +three virtues of infrastructure testing, in one file. diff --git a/examples/contract_fuzzer.rb b/examples/contract_fuzzer.rb new file mode 100644 index 0000000..a6f224e --- /dev/null +++ b/examples/contract_fuzzer.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# The Contract Fuzzer: for every registered capability, generate inputs +# that SHOULD pass its declared contract and mutations that SHOULD fail +# it, then check the validator agrees. A contract that accepts garbage +# or rejects conforming data is a bug in the boundary - the worst place +# to have one. +# +# bundle exec ruby examples/contract_fuzzer.rb [seed] +# +# Runs offline and deterministically: same seed, same verdicts. + +require_relative "../lib/agentic" + +seed = (ARGV.first || 20260706).to_i +rng = Random.new(seed) + +Agentic::Capabilities.register_standard_capabilities +registry = Agentic::AgentCapabilityRegistry.instance + +# Generates a value conforming to a declared type +def conforming_value(type, rng) + case type + when "string" then %w[alpha beta gamma delta].sample(random: rng) + when "number", "integer" then rng.rand(1..100) + when "boolean" then [true, false].sample(random: rng) + when "array" then Array.new(rng.rand(1..3)) { rng.rand(10) } + when "object", "hash" then {key: rng.rand(10)} + else "anything" + end +end + +# Generates a value that VIOLATES a declared type +def violating_value(type, rng) + case type + when "string" then rng.rand(1..100) + when "number", "integer" then "not a number" + when "boolean" then "yes" + when "array" then "not an array" + when "object", "hash" then 42 + end +end + +def conforming_inputs(spec, rng) + spec.inputs.to_h { |name, decl| [name, conforming_value(decl[:type], rng)] } +end + +verdicts = [] +trials = 0 + +registry.list.each_key do |name| + spec = registry.get(name) + validator = Agentic::CapabilityValidator.new(spec) + next if spec.inputs.empty? + + # Trial 1: conforming inputs must pass + trials += 1 + begin + validator.validate_inputs!(conforming_inputs(spec, rng)) + rescue Agentic::Errors::ValidationError => e + verdicts << "#{name}: REJECTED conforming inputs (#{e.message})" + end + + # Trial 2: each required key, dropped, must fail + spec.inputs.select { |_, decl| decl[:required] }.each_key do |required| + trials += 1 + inputs = conforming_inputs(spec, rng) + inputs.delete(required) + begin + validator.validate_inputs!(inputs) + verdicts << "#{name}: ACCEPTED inputs missing required :#{required}" + rescue Agentic::Errors::ValidationError + # correct rejection + end + end + + # Trial 3: each typed key, corrupted, must fail + spec.inputs.each do |key, decl| + corrupted = violating_value(decl[:type], rng) + next if corrupted.nil? + + trials += 1 + inputs = conforming_inputs(spec, rng).merge(key => corrupted) + begin + validator.validate_inputs!(inputs) + verdicts << "#{name}: ACCEPTED #{decl[:type]} key :#{key} holding #{corrupted.inspect}" + rescue Agentic::Errors::ValidationError + # correct rejection + end + end +end + +puts "CONTRACT FUZZ (seed #{seed})" +puts " #{registry.list.size} capabilities, #{trials} trials" +puts +if verdicts.empty? + puts " every contract accepted what it promised and rejected what it should." + puts " the boundary holds." +else + puts " BOUNDARY DEFECTS (#{verdicts.size}):" + verdicts.each { |v| puts " - #{v}" } + exit 1 +end From ddd7cd5c135c89019d68b7b7b2ec1ae48c5c8778 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:25:21 +0000 Subject: [PATCH 31/46] docs: typed command bus example (solnic round 3) Commands as composed capabilities with their own contracts (the round-3 compose inputs:/outputs: feature); dispatch is lookup + validation + rescue-to-rejection. Contract rejections and domain rejections stay distinguishable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/07-solnic.md | 58 +++++++++++++ examples/command_bus.rb | 114 +++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 docs/perspectives/round-3/07-solnic.md create mode 100644 examples/command_bus.rb diff --git a/docs/perspectives/round-3/07-solnic.md b/docs/perspectives/round-3/07-solnic.md new file mode 100644 index 0000000..02c6aa0 --- /dev/null +++ b/docs/perspectives/round-3/07-solnic.md @@ -0,0 +1,58 @@ +# Round 3 field notes — Piotr Solnica builds a typed command bus + +*Built: `examples/command_bus.rb` — commands are composed capabilities +with their own declared contracts; the bus is validation plus routing +and nothing else.* + +## What I built and why + +In round 2 I flagged one gap: `registry.compose` fused capabilities +into pipelines but the *composition itself* had no contract — types +everywhere except the seam users actually touch. That gap was closed in +the roadmap release (`compose(..., inputs:, outputs:)`), so I built the +pattern that gap was blocking: a **command bus** where every command is +a composition with a contract of its own. + +`PlaceOrder` composes `reserve_stock` + `record_entry` and declares +`{sku: string, quantity: number}` in, `{accepted: bool, events: array}` +out. Dispatching is four lines: look up the provider, execute, rescue +`ValidationError` into a rejection event. The run shows the shape I +care about most: + +``` +REJECTED PlaceOrder(sku: "widget", quantity: "many") + -> CommandRejected: quantity must be Numeric +REJECTED PlaceOrder(sku: "widget", quantity: 13) + -> OrderRejected: insufficient stock for widget +``` + +Two rejections, two *different layers*, both named. The contract +stopped `"many"` before any handler ran — the stock count never even +got read. The domain stopped 13 after consulting the shelf. **Types +stop nonsense; domains stop mistakes.** When those two rejections flow +through one undifferentiated `rescue => e`, every command handler +reimplements the difference badly; when the boundary is a typed +artifact, the bus does it once. + +## Notes from building on the improved seam + +- The composed contract validates *both directions*: while iterating I + briefly returned `events: "OrderPlaced"` (a string, not an array) and + the composition's own output contract caught my handler in the act. + Compositions that police themselves are what I asked for; it is + pleasant to be the first customer. +- The bus needed no bus class. Registry lookup *is* routing; contract + validation *is* input handling; the whole dispatch mechanism is a + method. When infrastructure disappears into the type layer, that's + usually the sign the type layer is placed correctly. +- Remaining wish, carried over from Jeremy's fuzzer notes: contract + expressiveness. `quantity: number` accepts `-3`, and no declared type + can currently say "positive integer" or "one of :standard, :express". + Predicates on declared keys (dry-logic is *right there*) would let + the boundary absorb another band of what is currently handler code. + +## Verdict + +Round 2 the pipeline had typed stages and an untyped whole; round 3 +the whole has a contract and a four-line bus makes it a system. +Boundaries first, then the pattern falls out — every time. diff --git a/examples/command_bus.rb b/examples/command_bus.rb new file mode 100644 index 0000000..6c061e9 --- /dev/null +++ b/examples/command_bus.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# The Command Bus: every command is a composed capability with its OWN +# declared contract (new in this round - compositions used to be +# contract-less). The bus is just the registry: dispatching a command +# validates it at the boundary, routes it through its handler pipeline, +# and validates what comes back. +# +# bundle exec ruby examples/command_bus.rb +# +# Runs offline. Watch PlaceOrder(quantity: "many") bounce off the +# boundary with the violation named. + +require_relative "../lib/agentic" + +registry = Agentic::AgentCapabilityRegistry.instance + +# --- primitive capabilities: small, reusable handler steps ----------------- +def capability(name, inputs:, outputs:, &impl) + spec = Agentic::CapabilitySpecification.new( + name: name, description: name, version: "1.0.0", inputs: inputs, outputs: outputs + ) + Agentic.register_capability( + spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) + ) +end + +STOCK = Hash.new(10) +LEDGER = [] + +capability("reserve_stock", + inputs: {sku: {type: "string", required: true}, quantity: {type: "number", required: true}}, + outputs: {reserved: {type: "boolean", required: true}, remaining: {type: "number", required: true}}) do |input| + available = STOCK[input[:sku]] + reserved = available >= input[:quantity] + STOCK[input[:sku]] -= input[:quantity] if reserved + {reserved: reserved, remaining: STOCK[input[:sku]]} +end + +capability("record_entry", + inputs: {entry: {type: "string", required: true}}, + outputs: {position: {type: "number", required: true}}) do |input| + LEDGER << input[:entry] + {position: LEDGER.size} +end + +# --- commands: compositions with their own contracts ------------------------ +registry.compose( + "PlaceOrder", "Place an order for a SKU", "1.0.0", + [{name: "reserve_stock", version: "1.0.0"}, {name: "record_entry", version: "1.0.0"}], + lambda do |(reserve, record), command| + reservation = reserve.execute(sku: command[:sku], quantity: command[:quantity]) + unless reservation[:reserved] + next {accepted: false, events: ["OrderRejected: insufficient stock for #{command[:sku]}"]} + end + + entry = record.execute(entry: "order #{command[:sku]} x#{command[:quantity]}") + {accepted: true, events: ["StockReserved(#{reservation[:remaining]} left)", "OrderPlaced(##{entry[:position]})"]} + end, + inputs: { + sku: {type: "string", required: true}, + quantity: {type: "number", required: true} + }, + outputs: { + accepted: {type: "boolean", required: true}, + events: {type: "array", required: true} + } +) + +registry.compose( + "RestockShelf", "Add stock for a SKU", "1.0.0", + [{name: "record_entry", version: "1.0.0"}], + lambda do |(record), command| + STOCK[command[:sku]] += command[:quantity] + entry = record.execute(entry: "restock #{command[:sku]} +#{command[:quantity]}") + {accepted: true, events: ["ShelfRestocked(##{entry[:position]})"]} + end, + inputs: {sku: {type: "string", required: true}, quantity: {type: "number", required: true}}, + outputs: {accepted: {type: "boolean", required: true}, events: {type: "array", required: true}} +) + +# --- the bus: dispatch is validation + routing, nothing else ---------------- +def dispatch(registry, command_name, payload) + provider = registry.get_provider(command_name) or + return {accepted: false, events: ["UnknownCommand: #{command_name}"]} + provider.execute(payload) +rescue Agentic::Errors::ValidationError => e + {accepted: false, + events: e.violations.map { |key, msgs| "CommandRejected: #{key} #{Array(msgs).join(", ")}" }} +end + +COMMANDS = [ + ["PlaceOrder", {sku: "widget", quantity: 3}], + ["PlaceOrder", {sku: "widget", quantity: "many"}], # violates the contract + ["RestockShelf", {sku: "widget", quantity: 5}], + ["PlaceOrder", {sku: "widget", quantity: 13}], # violates the business rule + ["ShipRocket", {to: "the moon"}] # nobody handles this +].freeze + +puts "COMMAND BUS" +puts +COMMANDS.each do |name, payload| + result = dispatch(registry, name, payload) + status = result[:accepted] ? "ACCEPTED" : "REJECTED" + puts format(" %-8s %s(%s)", status, name, payload.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")) + result[:events].each { |event| puts " -> #{event}" } +end + +puts +puts "ledger: #{LEDGER.size} entries | widget stock: #{STOCK["widget"]}" +puts +puts "note the two different REJECTED shapes: the contract rejected" +puts "'many' before any handler ran; the business rule rejected 13 after" +puts "checking the shelf. types stop nonsense, domains stop mistakes." From 1ac4072ba111bb2913501bc5b3f640b3c285a113 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:26:57 +0000 Subject: [PATCH 32/46] docs: flaky API drill example (mperham round 3) Scripted-flaky task under exponential backoff with journaled retries; the timeline proves the backoff waits and the journal answers completed? by description across runs. Field notes flag the retryable-errors string matching and jitter default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/08-mperham.md | 64 +++++++++++++++++++ examples/flaky_api_drill.rb | 84 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/perspectives/round-3/08-mperham.md create mode 100644 examples/flaky_api_drill.rb diff --git a/docs/perspectives/round-3/08-mperham.md b/docs/perspectives/round-3/08-mperham.md new file mode 100644 index 0000000..969ce22 --- /dev/null +++ b/docs/perspectives/round-3/08-mperham.md @@ -0,0 +1,64 @@ +# Round 3 field notes — Mike Perham runs the Flaky API Drill + +*Built: `examples/flaky_api_drill.rb` — a scripted-flaky task under a +real retry policy with exponential backoff, journaled end to end.* + +## What I built and why + +Every reliability feature is a rumor until you watch it under failure. +The drill scripts the failure: an API that times out twice and delivers +on the third call, run with `max_retries: 3`, exponential backoff from +100ms, and the journal recording everything. The timeline is the +receipt: + +``` + 51ms > attempt 1 ... + 53ms x attempt failed: TimeoutError + 155ms > attempt 2 ... <- ~100ms backoff, as configured + 156ms x attempt failed: TimeoutError + 359ms > attempt 3 ... <- ~200ms backoff, doubled + 360ms + sync:accounts succeeded + 362ms + audit:trail succeeded: "audited 42 accounts" +``` + +Those gaps — 100ms, then 200ms — are the point. Before Samuel's round-1 +fix, the backoff code *computed* those delays, spawned a detached fiber +to sleep them, and retried immediately. The unit tests passed the whole +time because they asserted `sleep` was called, not that anything +waited. This drill is the test those tests should have been: wall-clock +timestamps on real retries. Reliability claims get verified in the +timeline or not at all. + +## What the improved framework contributed + +- **Journal idempotency keys** (my round-2 gap, closed in the roadmap + release): `state.completed?("sync:accounts")` answers **by name**. + Task ids are per-run UUIDs; descriptions survive reruns. The + resume-after-crash pattern from my durable batch no longer needs the + hand-joined event mapping — one method call. +- The journal keeps the *failures* too: two `task_failed` events with + error types on disk next to the successes. When ops asks "how flaky + was the upstream last night," the answer is `grep task_failed`, not + archaeology. +- The dependent `audit:trail` task shows retries compose with piping: + it waited through the whole ordeal and then read the final output via + `t.output_of(sync)`. Downstream tasks don't know retries happened — + which is exactly the abstraction boundary you want. + +## What I'd still harden + +- `retryable_errors: ["TimeoutError"]` matches class names as strings — + fine until someone's error is `Net::ReadTimeout` or a namespaced + `Errors::LlmTimeoutError` (which has a `retryable?` method the policy + ignores!). The retry policy should consult `failure.retryable?` when + the error object offers it, and fall back to the list. +- Backoff still lacks jitter-by-default. Two hundred workers retrying + an upstream on the same exponential schedule is a synchronized + stampede; `backoff_jitter: true` exists but defaults off. Reliability + defaults should assume the crowd. + +## Verdict + +Retries that wait, a journal that remembers failures, resume keyed by +name. The drill passed on the first run — which, given what the suite +used to hide, is the sentence worth framing. diff --git a/examples/flaky_api_drill.rb b/examples/flaky_api_drill.rb new file mode 100644 index 0000000..5b1bd81 --- /dev/null +++ b/examples/flaky_api_drill.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# The Flaky API Drill: a task that times out twice before succeeding, +# run under a retry policy with exponential backoff and a journal. +# The timeline shows every attempt, every backoff gap, and the journal +# proves the whole ordeal - failures included - survived to disk. +# +# bundle exec ruby examples/flaky_api_drill.rb +# +# Runs offline; the flakiness is scripted so the drill is repeatable. + +require_relative "../lib/agentic" +require "tmpdir" + +# The error class name must match the retry policy's retryable_errors +class TimeoutError < StandardError; end + +JOURNAL = File.join(Dir.tmpdir, "agentic_flaky_drill.journal.jsonl") +File.delete(JOURNAL) if File.exist?(JOURNAL) + +journal = Agentic::ExecutionJournal.new(path: JOURNAL) +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +stamp = -> { format("%5dms", (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000) } + +timeline_hooks = journal.lifecycle_hooks( + after_task_failure: ->(task_id:, task:, failure:, duration:) { + puts "#{stamp.call} x attempt failed: #{failure.type} (#{failure.message})" + }, + after_task_success: ->(task_id:, task:, result:, duration:) { + puts "#{stamp.call} + #{task.description} succeeded: #{result.output.inspect}" + } +) + +orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, + lifecycle_hooks: timeline_hooks, + retry_policy: { + max_retries: 3, + retryable_errors: ["TimeoutError"], + backoff_strategy: :exponential, + backoff_base: 0.1 + } +) + +# Fails twice with a retryable timeout, then delivers +attempts = 0 +sync = Agentic::Task.new( + description: "sync:accounts", + agent_spec: {"name" => "AccountSync", "instructions" => "sync"}, + payload: nil +) +orchestrator.add_task(sync, agent: ->(_t) { + attempts += 1 + puts "#{stamp.call} > attempt #{attempts} calling the flaky API..." + raise TimeoutError, "upstream took too long" if attempts < 3 + + {"synced" => 42} +}) + +# An innocent bystander task, to show the plan keeps moving +audit = Agentic::Task.new( + description: "audit:trail", + agent_spec: {"name" => "Auditor", "instructions" => "audit"} +) +orchestrator.add_task(audit, [sync], agent: ->(t) { + "audited #{t.output_of(sync)["synced"]} accounts" +}) + +puts "FLAKY API DRILL (max 3 retries, exponential backoff from 100ms)" +puts +result = orchestrator.execute_plan +puts +puts "plan: #{result.status} in #{(result.execution_time * 1000).round}ms, " \ + "#{attempts} attempts for one success" + +state = Agentic::ExecutionJournal.replay(path: JOURNAL) +failures = state.events.count { |e| e[:event] == "task_failed" } +successes = state.events.count { |e| e[:event] == "task_succeeded" } +puts +puts "the journal remembers the whole ordeal:" +puts " #{failures} failed attempts and #{successes} successes on disk" +puts " completed?(\"sync:accounts\") => #{state.completed?("sync:accounts")} (by name - " \ + "a rerun tomorrow gets new task ids and still knows)" +puts " completed?(\"audit:trail\") => #{state.completed?("audit:trail")}" From d7b5713de6d6fbc41be3f7da5f5244d31706760c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:28:17 +0000 Subject: [PATCH 33/46] docs: collaboration tracer example (Sandi Metz round 3) Lifecycle hooks record every orchestrator message and reply; the run renders as an ASCII sequence diagram, making the mediator pattern - and the newly framework-owned output hand-off - visible and teachable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-3/09-sandimetz.md | 55 ++++++++++++++ examples/collaboration_tracer.rb | 89 +++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 docs/perspectives/round-3/09-sandimetz.md create mode 100644 examples/collaboration_tracer.rb diff --git a/docs/perspectives/round-3/09-sandimetz.md b/docs/perspectives/round-3/09-sandimetz.md new file mode 100644 index 0000000..ee49b0b --- /dev/null +++ b/docs/perspectives/round-3/09-sandimetz.md @@ -0,0 +1,55 @@ +# Round 3 field notes — Sandi Metz traces the conversation + +*Built: `examples/collaboration_tracer.rb` — lifecycle hooks record +every message and reply; the run renders as a sequence diagram.* + +## What I built and why + +I teach that an object-oriented design *is* its messages — the classes +are just where messages live between sendings. An agent plan is the +same thing at a larger grain: the orchestrator addresses collaborators, +work flows back, outputs travel forward. So the teaching tool builds +itself: hook the lifecycle, record `{from, to, label}` triples, draw +lifelines and arrows. A three-agent editorial pipeline traces as eight +messages, and you can *read the design* off the page: perform goes out, +a reply comes back, the reply travels forward as "here's..." to the +next collaborator. + +The diagram teaches something subtle that the code doesn't say +loudly: **all messages route through the orchestrator.** Researcher +never addresses Writer — the orchestrator relays. That's a mediator +pattern, drawn plainly enough to discuss its trade-offs with a student: +mediators centralize coupling (good: collaborators don't know each +other) and centralize knowledge (risk: the mediator grows). You can +have that conversation in front of this diagram in a way you cannot in +front of `plan_orchestrator.rb`. + +## What the improved framework gave the trace + +- The "here's ..." arrows — outputs traveling to dependents — only + exist because piping is now a framework event I can observe from a + hook (`task.dependency_outputs` is populated before + `before_task_execution` fires; the ordering choice made this tool + possible). In round 2 that hand-off happened in *user* code, where no + hook could see it. When a framework absorbs a responsibility, the + responsibility becomes observable, testable, drawable. That is the + strongest argument for absorbing it. +- Each stage's work rode along as a lambda in `payload`. The tracer + needed zero knowledge of what any collaborator does — it draws only + who-said-what-to-whom, which is the correct ignorance for a + collaboration diagram. + +## An honest note on my own rendering code + +The diagram code is procedural string-poking — `line[pos] = "|"` — and +I left it that way on purpose. Not every fifty lines deserves objects; +extraction is a response to *pressure*, and a single-use renderer with +no variation points exerts none. Knowing when not to design is part of +design. (If a second output format ever appears, `Message` and +`Lifeline` are waiting.) + +## Verdict + +The framework's message-passing is now visible enough to teach from. +Round 1 I critiqued the code; round 3 the code can critique itself in +front of a classroom — that's the better position. diff --git a/examples/collaboration_tracer.rb b/examples/collaboration_tracer.rb new file mode 100644 index 0000000..f35c8e4 --- /dev/null +++ b/examples/collaboration_tracer.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# The Collaboration Tracer: lifecycle hooks record every message the +# orchestrator sends and every reply that comes back, then the run is +# drawn as a sequence diagram. Object-oriented programs are +# conversations; this makes the conversation visible. +# +# bundle exec ruby examples/collaboration_tracer.rb +# +# Runs offline: a three-agent editorial pipeline, traced. + +require_relative "../lib/agentic" + +PIPELINE = { + "Researcher" => {work: ->(_prev) { "3 facts about fibers" }}, + "Writer" => {work: ->(prev) { "draft built on: #{prev}" }}, + "Editor" => {work: ->(prev) { "tightened: #{prev.split(":").first}" }} +}.freeze + +trace = [] +hooks = { + before_task_execution: ->(task_id:, task:) { + trace << {from: "Orchestrator", to: task.description, label: "perform(#{task.description.downcase})"} + unless task.dependency_outputs.empty? + task.dependency_outputs.each_value do |output| + trace << {from: "Orchestrator", to: task.description, label: "here's \"#{output.to_s[0, 18]}...\""} + end + end + }, + after_task_success: ->(task_id:, task:, result:, duration:) { + trace << {from: task.description, to: "Orchestrator", label: "done: \"#{result.output.to_s[0, 18]}...\""} + } +} + +orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: hooks) +previous = nil +PIPELINE.each do |role, spec| + task = Agentic::Task.new( + description: role, + agent_spec: {"name" => role, "instructions" => "collaborate"}, + payload: spec[:work] + ) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { + t.payload.call(t.dependency_outputs.values.first) + }) + previous = task +end + +orchestrator.execute_plan + +# --- render the conversation as a sequence diagram -------------------------- +actors = ["Orchestrator"] + PIPELINE.keys +width = 16 +positions = actors.each_with_index.to_h { |actor, i| [actor, i * width + width / 2] } +line_width = actors.size * width + +puts "COLLABORATION TRACE (#{trace.size} messages)" +puts +puts actors.map { |a| a.center(width) }.join +puts positions.values.each_with_object(" " * line_width) { |pos, line| + line[pos] = "|" + } + +trace.each do |message| + from_pos = positions[message[:from]] + to_pos = positions[message[:to]] + left, right = [from_pos, to_pos].minmax + + # the arrow line, with lifelines drawn through + line = " " * line_width + positions.each_value { |pos| line[pos] = "|" } + (left + 1...right).each { |i| line[i] = "-" } + line[(from_pos < to_pos) ? right - 1 : left + 1] = (from_pos < to_pos) ? ">" : "<" + puts line + + # the label line + label = " " * line_width + positions.each_value { |pos| label[pos] = "|" } + text = message[:label][0, right - left - 3] + label[left + 2, text.length] = text + puts label +end + +puts positions.values.each_with_object(" " * line_width) { |pos, line| + line[pos] = "|" + } +puts +puts "read it like a conversation: every arrow is a message, every" +puts "reply flows back before the next collaborator is addressed." From 6c8e3d66e4737c30fc4ec4dabbe54137d9463743 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:30:55 +0000 Subject: [PATCH 34/46] docs: changelog scout example and round-3 index (ankane round 3) Release notes drafted from real git history via a contract-checked classifier fanned across 40 commits into one writer. The perspectives README gains the round-3 table and findings: adapter tax eliminated, the Gantt-discovered deadlock fixed, and the next prioritized asks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/README.md | 39 ++++++++++++ docs/perspectives/round-3/10-ankane.md | 51 ++++++++++++++++ examples/changelog_scout.rb | 82 ++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 docs/perspectives/round-3/10-ankane.md create mode 100644 examples/changelog_scout.rb diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md index fd54fb6..8b53479 100644 --- a/docs/perspectives/README.md +++ b/docs/perspectives/README.md @@ -44,6 +44,45 @@ what building on the framework actually felt like. | 9 | Sandi Metz | Refactoring Dojo — three critics, one prescribed next step | `examples/refactoring_dojo.rb` | [round-2/09-sandimetz.md](round-2/09-sandimetz.md) | | 10 | Andrew Kane | Gem Scout — search + score pipeline on the pluggable backend | `examples/gem_scout.rb` | [round-2/10-ankane.md](round-2/10-ankane.md) | +## Round 3 — new experiments on the improved framework + +The round-2 consensus was delivered as a release (task payloads, direct +agents/callables, dependency output piping, provider-optional +`execute_plan`, composed-capability contracts, journal idempotency keys, +and the concurrency documentation — plus a scheduler deadlock fix found +by one of these builds). The personas then built ten *new* things: + +| # | Persona | Built with the improved gem | Run it | Field notes | +|---|---------|------------------------------|--------|-------------| +| 1 | Matz | Telephone game — piping as the whole program | `examples/telephone_game.rb` | [round-3/01-matz.md](round-3/01-matz.md) | +| 2 | DHH | Standup digest — parallel collectors fan into one writer | `examples/standup_digest.rb` | [round-3/02-dhh.md](round-3/02-dhh.md) | +| 3 | Aaron Patterson | Plan Gantt — ASCII execution timeline (found a scheduler deadlock) | `examples/plan_gantt.rb` | [round-3/03-tenderlove.md](round-3/03-tenderlove.md) | +| 4 | Xavier Noria | Documentation surveyor — 90.2% YARD coverage, fan-in report | `examples/doc_coverage.rb` | [round-3/04-fxn.md](round-3/04-fxn.md) | +| 5 | Samuel Williams | Live dashboard — hooks → `Async::Queue` → live renderer | `examples/live_dashboard.rb` | [round-3/05-ioquatix.md](round-3/05-ioquatix.md) | +| 6 | Jeremy Evans | Contract fuzzer — seed-deterministic boundary attack, 34 trials | `examples/contract_fuzzer.rb` | [round-3/06-jeremyevans.md](round-3/06-jeremyevans.md) | +| 7 | Piotr Solnica | Command bus — commands as contract-bearing compositions | `examples/command_bus.rb` | [round-3/07-solnic.md](round-3/07-solnic.md) | +| 8 | Mike Perham | Flaky API drill — retries that provably wait, journaled | `examples/flaky_api_drill.rb` | [round-3/08-mperham.md](round-3/08-mperham.md) | +| 9 | Sandi Metz | Collaboration tracer — plans as sequence diagrams | `examples/collaboration_tracer.rb` | [round-3/09-sandimetz.md](round-3/09-sandimetz.md) | +| 10 | Andrew Kane | Changelog scout — release notes from real git history | `examples/changelog_scout.rb` | [round-3/10-ankane.md](round-3/10-ankane.md) | + +### What round 3 surfaced + +1. **The adapter tax is gone.** Zero provider structs, zero + string-keyed lookups across all ten builds; several programs are + shorter than their round-2 counterparts while doing more. +2. **A real scheduler deadlock** — fan-in dependencies at a tight + concurrency limit deadlocked slot-holders spawning dependents. Found + by the Gantt chart, fixed (spawn through the barrier, acquire inside + the fiber), regression-tested. +3. **Piping enabled new shapes**: fan-in aggregation (digest, doc + coverage, changelog), observable hand-offs (collaboration tracer), + and retry-transparent downstream reads (flaky drill). +4. **Next asks, in priority order**: named dependencies + (`needs: {facts: task}`), a `previous_output` convenience for + single-dependency chains, a `task_slot_acquired` hook to split queue + time from run time, `failure.retryable?` consulted by retry + policies, and richer contract predicates (ranges/enums). + ### What round 2 taught (the consumer's consensus) Building *with* the gem surfaced different findings than reviewing it: diff --git a/docs/perspectives/round-3/10-ankane.md b/docs/perspectives/round-3/10-ankane.md new file mode 100644 index 0000000..0d33b04 --- /dev/null +++ b/docs/perspectives/round-3/10-ankane.md @@ -0,0 +1,51 @@ +# Round 3 field notes — Andrew Kane ships the Changelog Scout + +*Built: `examples/changelog_scout.rb` — classifies real git history +through a contract-checked capability and drafts release notes: +features, fixes, internals, and a one-line summary of the quiet work.* + +## What I built and why + +The release-notes chore, automated the way I'd actually automate it: +one `classify_commit` capability (subject in; kind, cleaned note, +breaking-flag out), one task per commit fanned out at concurrency 8, +one writer task that fans all forty classifications in and drafts the +markdown. Real repo, real history, 50ms. + +And the demo gods smiled: pointed at this branch, the scout's output +*is the summary of this whole experiment* — ten features, four fixes +(the suite truncation, the reactor nesting, the logger level, the +scheduler deadlock), one internal, twenty-five docs commits. A tool +that documents the project that built it on its first run is a tool +I'd package tonight. + +## The design choice worth copying + +The classifier is a deterministic lambda *behind a declared contract*. +Conventional-commit parsing covers 95% of real subjects for free — and +when you want an LLM to handle the messy 5% ("various fixes", "wip", +the Friday-afternoon specials), you swap the lambda for a client call +and **nothing else changes**, because the contract +(`kind/note/breaking`) is the interface the writer consumes. Start +deterministic, upgrade selectively, keep the seam typed. That's the +whole playbook for sprinkling LLMs into working software without +letting them eat the architecture. + +## Scorekeeping across three rounds + +I keep count of when the orchestrator earns its keep versus plain +capability calls. This one earns it twice: real fan-out (40 commits) +*and* fan-in (the writer needs all classifications). In round 2 I built +Gem Scout without the orchestrator because two sequential calls didn't +need a scheduler — and I stand by the rule the README now prints: +capabilities first, orchestrator when there's a queue, planner when +the task list itself should come from a model. The framework finally +documents its own gradient. Frameworks that tell you when *not* to use +their big hammer are the ones that survive. + +## What I'd ship next + +`--since v0.2.0` (tag-to-HEAD range), a `CHANGELOG.md` writer mode, and +a `--llm` flag that routes only unparseable subjects to a model. At +that point: `gem install changelog_scout`. Examples should keep +graduating into gems — that's the ecosystem working as intended. diff --git a/examples/changelog_scout.rb b/examples/changelog_scout.rb new file mode 100644 index 0000000..afd2f4a --- /dev/null +++ b/examples/changelog_scout.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# The Changelog Scout: reads real git history, classifies every commit +# through a contract-checked capability, and drafts the release notes - +# features first, fixes second, docs summarized in one line. +# +# bundle exec ruby examples/changelog_scout.rb [commit_count] +# +# Runs offline against the current repo. Swap the classifier lambda for +# an LLM client when you want prose instead of parsing - the contract +# stays identical. + +require_relative "../lib/agentic" + +ROOT = File.expand_path("..", __dir__) +count = (ARGV.first || 40).to_i + +# --- the classifier: one commit in, one classified entry out --------------- +spec = Agentic::CapabilitySpecification.new( + name: "classify_commit", + description: "Classify one commit subject for release notes", + version: "1.0.0", + inputs: {subject: {type: "string", required: true}}, + outputs: { + kind: {type: "string", required: true}, + note: {type: "string", required: true}, + breaking: {type: "boolean", required: true} + } +) +Agentic.register_capability(spec, Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { + subject = inputs[:subject] + kind = subject[/\A(feat|fix|docs|refactor|test|chore)/, 1] || "other" + note = subject.sub(/\A\w+(\([^)]*\))?!?:\s*/, "").sub(/\A(.)/) { $1.upcase } + {kind: kind, note: note, breaking: subject.include?("!:")} + } +)) + +scribe = Agentic::Agent.build { |a| a.name = "Scribe" } +scribe.add_capability("classify_commit") + +# --- the plan: classify commits in parallel, then one writer fans in -------- +subjects = `git -C #{ROOT} log -#{count} --pretty=format:%s`.lines.map(&:strip) + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) +classifications = subjects.map.with_index do |subject, i| + task = Agentic::Task.new( + description: "commit #{i + 1}", + agent_spec: {"name" => "Scribe", "instructions" => "classify"}, + payload: subject + ) + orchestrator.add_task(task, agent: ->(t) { + scribe.execute_capability("classify_commit", {subject: t.payload}) + }) + task +end + +notes = Agentic::Task.new( + description: "release notes", + agent_spec: {"name" => "Editor", "instructions" => "draft the notes"} +) +orchestrator.add_task(notes, classifications, agent: ->(t) { + entries = classifications.map { |c| t.output_of(c) } + grouped = entries.group_by { |e| e[:kind] } + + sections = [] + sections << "## Breaking\n" + entries.select { |e| e[:breaking] }.map { |e| "- #{e[:note]}" }.join("\n") if entries.any? { |e| e[:breaking] } + {"feat" => "## Features", "fix" => "## Fixes", "refactor" => "## Internals"}.each do |kind, heading| + items = grouped[kind] or next + sections << "#{heading}\n#{items.map { |e| "- #{e[:note]}" }.join("\n")}" + end + quiet = grouped.slice("docs", "test", "chore", "other").values.flatten.size + sections << "_...plus #{quiet} documentation, test, and housekeeping commits._" if quiet.positive? + sections.join("\n\n") +}) + +result = orchestrator.execute_plan + +puts "RELEASE NOTES (last #{subjects.size} commits, drafted in #{(result.execution_time * 1000).round}ms)" +puts "=" * 60 +puts result.results[notes.id].output From 71c9b40b19dc0a16ff724bf4c6e9154f6aa47605 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:48:22 +0000 Subject: [PATCH 35/46] feat: deliver the round-3 asks The prioritized asks from the round-3 field notes, implemented: - Named dependencies: add_task(task, needs: {shipped: commits}) pipes outputs addressable as task.needs.shipped / task.needs[:shipped] (NamedOutputs), composing with positional dependencies - Task#previous_output shorthand for single-dependency chains - task_slot_acquired lifecycle hook with waited: duration, separating queue wait from run time; the Gantt example now renders '.' for queued and '#' for running - Retry policies consult the error's own retryable? verdict (captured through TaskFailure.from_exception) before the retryable_errors type list; Task#perform now preserves it too - Contract value predicates: enum:, min:/max:, and non_empty: on declared capability inputs/outputs, enforced via dry-schema - Hooks documented as running inline on the task's fiber Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- README.md | 20 ++++ examples/plan_gantt.rb | 12 +- lib/agentic/capability_validator.rb | 22 +++- lib/agentic/named_outputs.rb | 50 ++++++++ lib/agentic/plan_orchestrator.rb | 44 ++++++- lib/agentic/task.rb | 25 ++-- lib/agentic/task_failure.rb | 21 +++- spec/agentic/round4_features_spec.rb | 167 +++++++++++++++++++++++++++ 8 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 lib/agentic/named_outputs.rb create mode 100644 spec/agentic/round4_features_spec.rb diff --git a/README.md b/README.md index efba3a8..19930e0 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,26 @@ result = orchestrator.execute_plan # no provider needed A plan-wide block acts as an agent factory when you do want one place that builds agents: `orchestrator.execute_plan { |task| build_agent_for(task) }`. +Dependencies can be **named**, so they're declared and consumed under one +word, and single-dependency chains have a shorthand: + +```ruby +orchestrator.add_task(digest, needs: {shipped: commits, owed: debt}, agent: ->(task) { + "#{task.needs.shipped} commits shipped, #{task.needs.owed} TODOs owed" +}) + +orchestrator.add_task(next_verse, [previous_verse], agent: ->(task) { + answer(task.previous_output) # the sole dependency's output +}) +``` + +Capability contracts can constrain values beyond type and presence — +`enum: %w[standard express]`, `min:`/`max:` bounds, and `non_empty: true` +for strings and arrays. The `task_slot_acquired` lifecycle hook fires when +a task obtains a concurrency slot (with `waited:` time), separating queue +wait from run time; and retry policies consult an error's own +`retryable?` verdict before falling back to the `retryable_errors` list. + ### The concurrency contract Tasks run as fibers inside an [async](https://github.com/socketry/async) diff --git a/examples/plan_gantt.rb b/examples/plan_gantt.rb index 9808fe1..7bac663 100644 --- a/examples/plan_gantt.rb +++ b/examples/plan_gantt.rb @@ -30,6 +30,10 @@ (timeline[task.description] ||= {})[:start] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start }, + task_slot_acquired: ->(task_id:, task:, waited:) { + timeline[task.description][:running] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start + }, after_task_success: ->(task_id:, task:, result:, duration:) { timeline[task.description][:finish] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start @@ -60,13 +64,15 @@ puts timeline.each do |name, t| from = (t[:start] * 100).round - width = [((t[:finish] - t[:start]) * 100).round, 1].max - bar = ((" " * from) + ("#" * width)).ljust(columns) + slot = ((t[:running] || t[:start]) * 100).round + queued = [slot - from, 0].max + width = [((t[:finish] - (t[:running] || t[:start])) * 100).round, 1].max + bar = ((" " * from) + ("." * queued) + ("#" * width)).ljust(columns) puts format(" %-16s |%s| %3d-%3dms", name, bar, t[:start] * 1000, t[:finish] * 1000) end puts puts format(" %-16s |%s|", "", (0..columns).step(10).map { |c| (c / 10).to_s.ljust(10) }.join[0, columns + 1]) -puts " (one column = 10ms; numbers along the base are x100ms)" +puts " (one column = 10ms; '.' = queued for a slot, '#' = running)" puts serial_floor = WORK.values.sum { |w| w[:sleep] } puts format(" serial floor %.0fms -> actual %.0fms (%.1fx from the scheduler)", diff --git a/lib/agentic/capability_validator.rb b/lib/agentic/capability_validator.rb index ba122e7..972aa25 100644 --- a/lib/agentic/capability_validator.rb +++ b/lib/agentic/capability_validator.rb @@ -60,13 +60,23 @@ def schema_for(kind, declared) definition ||= {} key = definition[:required] ? required(name.to_sym) : optional(name.to_sym) + # Beyond type and presence, declarations may constrain values: + # enum: [...] - value must be one of these + # min:/max: - numeric bounds (inclusive) + # non_empty: true - strings/arrays must not be empty + predicates = {} + predicates[:included_in?] = definition[:enum] if definition[:enum] + predicates[:gteq?] = definition[:min] if definition[:min] + predicates[:lteq?] = definition[:max] if definition[:max] + predicates[:min_size?] = 1 if definition[:non_empty] + case definition[:type] - when "string" then key.value(:string) - when "number", "integer" then key.value(type?: Numeric) - when "boolean" then key.value(:bool) - when "array" then key.value(:array) - when "object", "hash" then key.value(:hash) - else key.value(type?: Object) + when "string" then key.value(:string, **predicates) + when "number", "integer" then key.value(type?: Numeric, **predicates) + when "boolean" then key.value(:bool, **predicates) + when "array" then key.value(:array, **predicates) + when "object", "hash" then key.value(:hash, **predicates) + else key.value(type?: Object, **predicates) end end end diff --git a/lib/agentic/named_outputs.rb b/lib/agentic/named_outputs.rb new file mode 100644 index 0000000..9df8302 --- /dev/null +++ b/lib/agentic/named_outputs.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Agentic + # Dependency outputs addressed by the name the consumer chose, not by + # task id. Built by the orchestrator for tasks added with `needs:`: + # + # orchestrator.add_task(digest, needs: {shipped: commits, owed: debt}) + # # inside the agent: + # task.needs.shipped # or task.needs[:shipped] + class NamedOutputs + def initialize + @outputs = {} + end + + # Assigns a named output (called by the orchestrator) + # @param name [Symbol, String] The name declared in needs: + # @param value [Object] The dependency's output + def []=(name, value) + @outputs[name.to_sym] = value + end + + # @param name [Symbol, String] The declared name + # @return [Object, nil] The named dependency's output + def [](name) + @outputs[name.to_sym] + end + + # @param name [Symbol, String] The declared name + # @return [Boolean] True when the named output has been assigned + def key?(name) + @outputs.key?(name.to_sym) + end + + # @return [Hash{Symbol=>Object}] A copy of all named outputs + def to_h + @outputs.dup + end + + # Named outputs read as methods: task.needs.shipped + def method_missing(name, *args) + return @outputs[name] if args.empty? && @outputs.key?(name) + + super + end + + def respond_to_missing?(name, include_private = false) + @outputs.key?(name) || super + end + end +end diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index ff94f31..b7d7ec7 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -36,6 +36,7 @@ def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: @concurrency_limit = concurrency_limit @async_tasks = {} @task_agents = {} + @task_needs = {} # Configure retry policy with defaults @retry_policy = { @@ -44,11 +45,14 @@ def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: backoff_strategy: :constant }.merge(retry_policy) - # Configure lifecycle hooks with callable defaults (no-ops) + # Configure lifecycle hooks with callable defaults (no-ops). + # Hooks run inline on the task's fiber - anything slower than a hash + # insert should hand off (e.g. enqueue onto an Async::Queue). @lifecycle_hooks = { before_agent_build: ->(task_id:, task:) {}, # Called before an agent is built after_agent_build: ->(task_id:, task:, agent:, build_duration:) {}, # Called after an agent is built - before_task_execution: ->(task_id:, task:) {}, # Called before a task is executed + before_task_execution: ->(task_id:, task:) {}, # Called when the task is scheduled (may still queue) + task_slot_acquired: ->(task_id:, task:, waited:) {}, # Called when a concurrency slot is acquired after_task_success: ->(task_id:, task:, result:, duration:) {}, # Called after a task succeeds after_task_failure: ->(task_id:, task:, failure:, duration:) {}, # Called after a task fails plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) {} # Called when plan completes @@ -60,16 +64,28 @@ def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: # Dependencies may be task ids or Task objects. An agent (anything # responding to #execute) or a bare callable (receives the task, # returns the output) can be attached directly, making a plan-wide - # agent provider optional. + # agent provider optional. Named dependencies declared via needs: are + # dependencies whose outputs arrive addressable by name: + # + # orchestrator.add_task(digest, needs: {shipped: commits, owed: debt}) + # # in the agent: task.needs.shipped # # @param task [Task] The task to add # @param dependencies [Array] Tasks (or ids) this task depends on # @param agent [#execute, #call, nil] The agent or callable to execute this task + # @param needs [Hash{Symbol=>Task,String}, nil] Named dependencies # @return [void] - def add_task(task, dependencies = [], agent: nil) + def add_task(task, dependencies = [], agent: nil, needs: nil) task_id = task.id @tasks[task_id] = task - @dependencies[task_id] = Array(dependencies).map { |dep| dep.respond_to?(:id) ? dep.id : dep } + deps = Array(dependencies).map { |dep| dep.respond_to?(:id) ? dep.id : dep } + + if needs + @task_needs[task_id] = needs.transform_values { |dep| dep.respond_to?(:id) ? dep.id : dep } + deps |= @task_needs[task_id].values + end + + @dependencies[task_id] = deps @task_agents[task_id] = agent if agent @execution_state[:pending].add(task_id) end @@ -185,6 +201,12 @@ def retry?(task:, failure:) task.retry_count ||= 0 return false if task.retry_count >= @retry_policy[:max_retries] + # An error's own retryability verdict outranks the type list - + # Errors::LlmRateLimitError knows it's retryable, an + # authentication error knows it isn't + verdict = failure.respond_to?(:retryable?) ? failure.retryable? : nil + return verdict unless verdict.nil? + # Check if error type is in retryable_errors list @retry_policy[:retryable_errors].include?(failure.type) end @@ -286,6 +308,12 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) end end + # Named dependencies arrive addressable by the caller's chosen name + @task_needs[task_id]&.each do |name, dependency_id| + dependency_result = @results[dependency_id] + task.needs[name] = dependency_result.output if dependency_result&.successful? + end + # Call before_task_execution hook @lifecycle_hooks[:before_task_execution].call( task_id: task_id, @@ -298,8 +326,14 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) # their dependents from within their own slot, so two slot-holders # spawning dependents at a tight concurrency limit would deadlock # waiting for each other's slots. + scheduled_at = Time.now async_task = barrier.async do semaphore.acquire do + @lifecycle_hooks[:task_slot_acquired].call( + task_id: task_id, + task: task, + waited: Time.now - scheduled_at + ) execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) end end diff --git a/lib/agentic/task.rb b/lib/agentic/task.rb index f5ec298..595ba10 100644 --- a/lib/agentic/task.rb +++ b/lib/agentic/task.rb @@ -89,6 +89,20 @@ def output_of(task_or_id) @dependency_outputs[task_or_id.respond_to?(:id) ? task_or_id.id : task_or_id] end + # The output of this task's sole (or first-completed) dependency - + # the common case in a chain, where naming the dependency is noise + # @return [Object, nil] The dependency's output + def previous_output + @dependency_outputs.values.first + end + + # Dependency outputs addressed by the names declared via + # add_task(task, needs: {name: dependency}) + # @return [NamedOutputs] + def needs + @needs ||= NamedOutputs.new + end + # Executes the task using the given agent # @param agent [Agent] The agent that will execute this task # @return [TaskResult] The result of the task execution @@ -115,14 +129,9 @@ def perform(agent) output: @output ) rescue => e - @failure = TaskFailure.new( - message: e.message, - type: e.class.name, - context: { - backtrace: e.backtrace&.first(10), - agent_id: agent.respond_to?(:id) ? agent.id : nil - } - ) + @failure = TaskFailure.from_exception(e, { + agent_id: agent.respond_to?(:id) ? agent.id : nil + }) old_status = @status @status = :failed diff --git a/lib/agentic/task_failure.rb b/lib/agentic/task_failure.rb index 9791991..87639f0 100644 --- a/lib/agentic/task_failure.rb +++ b/lib/agentic/task_failure.rb @@ -9,16 +9,28 @@ module Agentic class TaskFailure attr_reader :message, :type, :timestamp, :context + # @return [Boolean, nil] Whether the originating error declared itself + # retryable (nil when the error expressed no opinion) + attr_reader :retryable + # Initializes a new task failure # @param message [String] The failure message # @param type [String] The type of failure # @param context [Hash] Additional context about the failure + # @param retryable [Boolean, nil] The originating error's own retryability verdict # @return [TaskFailure] A new task failure instance - def initialize(message:, type:, context: {}) + def initialize(message:, type:, context: {}, retryable: nil) @message = message @type = type @timestamp = Time.now @context = context + @retryable = retryable + end + + # Whether the originating error declared itself retryable + # @return [Boolean, nil] nil when the error expressed no opinion + def retryable? + @retryable end # Returns a serializable representation of the failure @@ -28,11 +40,13 @@ def to_h message: @message, type: @type, timestamp: @timestamp.iso8601, - context: @context + context: @context, + retryable: @retryable } end - # Creates a task failure from an exception + # Creates a task failure from an exception, preserving the exception's + # own retryability verdict when it offers one (e.g. Errors::LlmRateLimitError) # @param exception [Exception] The exception # @param context [Hash] Additional context about the failure # @return [TaskFailure] A new task failure instance @@ -40,6 +54,7 @@ def self.from_exception(exception, context = {}) new( message: exception.message, type: exception.class.name, + retryable: exception.respond_to?(:retryable?) ? exception.retryable? : nil, context: context.merge( backtrace: exception.backtrace&.first(10) ) diff --git a/spec/agentic/round4_features_spec.rb b/spec/agentic/round4_features_spec.rb new file mode 100644 index 0000000..40f97bc --- /dev/null +++ b/spec/agentic/round4_features_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "round 4 framework features" do + def task_named(description, payload: nil) + Agentic::Task.new( + description: description, + agent_spec: {"name" => "worker", "instructions" => "work"}, + payload: payload + ) + end + + describe "named dependencies (needs:)" do + it "delivers dependency outputs addressable by name" do + orchestrator = Agentic::PlanOrchestrator.new + commits = task_named("commits") + debt = task_named("debt") + digest = task_named("digest") + + orchestrator.add_task(commits, agent: ->(_t) { 12 }) + orchestrator.add_task(debt, agent: ->(_t) { 3 }) + orchestrator.add_task(digest, needs: {shipped: commits, owed: debt}, agent: ->(t) { + "shipped #{t.needs.shipped}, owed #{t.needs[:owed]}" + }) + + result = orchestrator.execute_plan + + expect(result.results[digest.id].output).to eq("shipped 12, owed 3") + end + + it "combines needs: with positional dependencies" do + orchestrator = Agentic::PlanOrchestrator.new + gate = task_named("gate") + source = task_named("source") + sink = task_named("sink") + order = [] + + orchestrator.add_task(gate, agent: ->(_t) { order << :gate }) + orchestrator.add_task(source, agent: ->(_t) { :payload }) + orchestrator.add_task(sink, [gate], needs: {input: source}, agent: ->(t) { + order << :sink + t.needs.input + }) + + result = orchestrator.execute_plan + + expect(order).to eq([:gate, :sink]) + expect(result.results[sink.id].output).to eq(:payload) + end + end + + describe "Task#previous_output" do + it "returns the sole dependency's output in a chain" do + orchestrator = Agentic::PlanOrchestrator.new + first = task_named("first") + second = task_named("second") + + orchestrator.add_task(first, agent: ->(_t) { "whisper" }) + orchestrator.add_task(second, [first], agent: ->(t) { t.previous_output.upcase }) + + result = orchestrator.execute_plan + + expect(result.results[second.id].output).to eq("WHISPER") + end + end + + describe "task_slot_acquired hook" do + it "reports queue wait separately from scheduling" do + waits = {} + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, + lifecycle_hooks: { + task_slot_acquired: ->(task_id:, task:, waited:) { waits[task.description] = waited } + } + ) + a = task_named("a") + b = task_named("b") + orchestrator.add_task(a, agent: ->(_t) { sleep(0.05) || :ok }) + orchestrator.add_task(b, agent: ->(_t) { :ok }) + + orchestrator.execute_plan + + expect(waits["a"]).to be < 0.02 + expect(waits["b"]).to be >= 0.04 # queued behind a's 50ms slot + end + end + + describe "retry policy consulting failure.retryable?" do + let(:not_retryable) do + Class.new(StandardError) { + def retryable? = false + } + end + + it "does not retry when the error itself says no, even if the type list says yes" do + stub_const("VetoedError", not_retryable) + attempts = 0 + orchestrator = Agentic::PlanOrchestrator.new( + retry_policy: {max_retries: 3, retryable_errors: ["VetoedError"]} + ) + task = task_named("vetoed") + orchestrator.add_task(task, agent: ->(_t) { + attempts += 1 + raise VetoedError, "authentication is not going to improve" + }) + + result = orchestrator.execute_plan + + expect(attempts).to eq(1) + expect(result.results[task.id].failed?).to be true + end + + it "retries when the error says yes, even if the type list says no" do + eager = Class.new(StandardError) { + def retryable? = true + } + stub_const("EagerError", eager) + attempts = 0 + orchestrator = Agentic::PlanOrchestrator.new( + retry_policy: {max_retries: 2, retryable_errors: [], backoff_strategy: :none} + ) + task = task_named("eager") + orchestrator.add_task(task, agent: ->(_t) { + attempts += 1 + raise EagerError, "try me again" if attempts < 2 + :recovered + }) + + result = orchestrator.execute_plan + + expect(attempts).to eq(2) + expect(result.results[task.id].output).to eq(:recovered) + end + end + + describe "contract value predicates" do + let(:specification) do + Agentic::CapabilitySpecification.new( + name: "ship_order", + description: "Ships an order", + version: "1.0.0", + inputs: { + speed: {type: "string", required: true, enum: %w[standard express]}, + quantity: {type: "number", required: true, min: 1, max: 100}, + items: {type: "array", required: true, non_empty: true} + } + ) + end + + let(:validator) { Agentic::CapabilityValidator.new(specification) } + + it "accepts values inside the declared constraints" do + expect { + validator.validate_inputs!(speed: "express", quantity: 5, items: [:widget]) + }.not_to raise_error + end + + it "rejects enum violations, out-of-range numbers, and empty arrays by name" do + expect { + validator.validate_inputs!(speed: "teleport", quantity: 0, items: []) + }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.violations.keys).to contain_exactly(:speed, :quantity, :items) + } + end + end +end From a18af56563863583ae6c285df97dd419c889d5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:49:29 +0000 Subject: [PATCH 36/46] docs: exquisite corpse example (Matz round 4) Three artists draw creature parts in parallel; the assembler reads them by name via needs: - round 3's named-dependency ask as a parlor game. Seeded, reproducible creatures. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/01-matz.md | 53 ++++++++++++++++++++++++ examples/exquisite_corpse.rb | 62 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/perspectives/round-4/01-matz.md create mode 100644 examples/exquisite_corpse.rb diff --git a/docs/perspectives/round-4/01-matz.md b/docs/perspectives/round-4/01-matz.md new file mode 100644 index 0000000..a51930a --- /dev/null +++ b/docs/perspectives/round-4/01-matz.md @@ -0,0 +1,53 @@ +# Round 4 field notes — Matz unfolds the exquisite corpse + +*Built: `examples/exquisite_corpse.rb` — three artists draw a creature's +parts without peeking; the assembler reads them by name and unfolds the +paper.* + +## What I built and why + +The surrealists' parlor game is secretly a concurrency diagram: three +independent workers, no shared knowledge, one fan-in reveal. Last round +the reveal would have read `t.dependency_outputs.values` and prayed +about ordering; this round the assembler says what it means: + +```ruby +orchestrator.add_task(reveal, needs: artists, agent: ->(t) { + t.needs.head + t.needs.torso + t.needs.legs +}) +``` + +`needs: artists` — my artists hash *is already* the declaration. And in +the agent, `t.needs.head` reads like the sentence "the reveal needs the +head." When the declaration and the consumption share a vocabulary, +there is no translation step for a bug to live in. + +## Small delights + +- Seeded randomness makes every creature reproducible: seed 7 gives the + cat-headed armored thing with acrobat legs; a bug report about a + malformed monster comes with its seed attached. (Jeremy has fully + converted me on this.) +- `previous_output` — my other round-3 wish — I didn't even need here, + and that is its own lesson: the two conveniences serve different + sentence shapes. Chains say "answer what came before"; gatherings say + "bring me the head." A good API has grammar for both and forces + neither. + +## One more wish, smaller than the last + +`needs: artists` worked because my hash happened to map names to tasks. +Lovely. But `t.needs.head + t.needs.torso + t.needs.legs` still spells +the stacking order by hand — `t.needs.to_h.values` loses the order I +declared. If `NamedOutputs#to_h` preserved *declaration* order (it +does, Ruby hashes are ordered — but nothing promises it), the assembler +could be `t.needs.to_h.values.flatten`. Promise the order in the +documentation; ordered hashes are one of Ruby's quiet gifts, and +promises are what make gifts usable. + +## Verdict + +Round 3's asks became round 4's grammar. The game took twenty minutes, +most of it spent drawing ASCII torsos — which is to say the framework +has reached the correct level of invisibility: the hard part of the +program was the art. diff --git a/examples/exquisite_corpse.rb b/examples/exquisite_corpse.rb new file mode 100644 index 0000000..a223226 --- /dev/null +++ b/examples/exquisite_corpse.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# The Exquisite Corpse: three artists each draw one part of a creature +# without seeing the others' work; the assembler receives all three +# parts BY NAME and stacks them. The surrealists played this on folded +# paper; we play it on a dependency graph. +# +# bundle exec ruby examples/exquisite_corpse.rb [seed] +# +# Runs offline. Every seed is a different creature. + +require_relative "../lib/agentic" + +seed = (ARGV.first || rand(1000)).to_i +rng = Random.new(seed) + +PARTS = { + head: [ + [" /\\_/\\ ", " ( o.o ) ", " > ^ < "], + [" .---. ", " ( @ @ ) ", " \\_-_/ "], + [" ,***, ", " { > < } ", " \"---\" "] + ], + torso: [ + [" /|___|\\ ", " | (===) | ", " \\|___|/ "], + [" <#####> ", " |#####| ", " <#####> "], + [" )~~~( ", " ( ~~~ ) ", " )~~~( "] + ], + legs: [ + [" | | ", " | | ", " _| |_ "], + [" d b ", " | | ", " =$ $= "], + [" \\ / ", " \\ / ", " _/^\\_ "] + ] +}.freeze + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) + +artists = PARTS.to_h do |part, options| + task = Agentic::Task.new( + description: "draw the #{part}", + agent_spec: {"name" => "Artist of the #{part}", "instructions" => "draw without peeking"}, + payload: options + ) + orchestrator.add_task(task, agent: ->(t) { t.payload.sample(random: rng) }) + [part, task] +end + +reveal = Agentic::Task.new( + description: "unfold the paper", + agent_spec: {"name" => "Assembler", "instructions" => "stack the parts"} +) +orchestrator.add_task(reveal, needs: artists, agent: ->(t) { + t.needs.head + t.needs.torso + t.needs.legs +}) + +result = orchestrator.execute_plan + +puts "EXQUISITE CORPSE (seed #{seed})" +puts +result.results[reveal.id].output.each { |line| puts " #{line}" } +puts +puts "three artists, no peeking - the assembler read the parts by name:" +puts " t.needs.head, t.needs.torso, t.needs.legs" From 1f3cf71051bb5b7641256e547c83d7962637c5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:50:31 +0000 Subject: [PATCH 37/46] docs: setup doctor example (DHH round 4) Parallel environment checks with a named-dependency diagnosis and a CI-enforceable exit code - the onboarding wiki page, deleted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/02-dhh.md | 49 +++++++++++++++++++ examples/setup_doctor.rb | 76 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/perspectives/round-4/02-dhh.md create mode 100644 examples/setup_doctor.rb diff --git a/docs/perspectives/round-4/02-dhh.md b/docs/perspectives/round-4/02-dhh.md new file mode 100644 index 0000000..9e60ed2 --- /dev/null +++ b/docs/perspectives/round-4/02-dhh.md @@ -0,0 +1,49 @@ +# Round 4 field notes — DHH replaces the onboarding wiki + +*Built: `examples/setup_doctor.rb` — four environment checks in +parallel, one diagnosis reading them by name, one exit code.* + +## What I built and why + +Every onboarding wiki page is a bug report against your tooling. The +doctor runs what the wiki would ask a new hire to do by hand — ruby +version against the gemspec, `bundle check`, git state, test suite +presence — and prescribes. Green means "write code, not wiki pages." +Red means the FIX lines are your first day's checklist, and it exits 1 +so CI can enforce it. + +The shape I care about is the diagnosis: + +```ruby +orchestrator.add_task(diagnosis, + needs: {ruby: ruby, bundle: bundle, git: git, suite: suite}, + agent: ->(t) { ... t.needs.ruby ... }) +``` + +Last round I asked for exactly this — dependencies declared and +consumed under one name — and it shipped. `t.needs.bundle` is +self-documenting in a way `t.dependency_outputs.values[1]` never was. +The asymmetry I complained about is gone: the declaration IS the +consumption vocabulary. This is the API a Rails person expects, which +I mean as the highest compliment I give. + +## Omakase notes + +- The checks are real, not simulated — this doctor diagnosed the very + repo it lives in and told me I had one uncommitted change (it was + itself; the doctor detected its own birth, which is very Basecamp). +- Each check returns `{ok:, detail:}` — a convention, not a contract. + I *chose* not to give the checks capability contracts because for a + five-check doctor that's ceremony. The framework let me choose. The + gradient the README now documents (capabilities first, orchestrator + for queues) works in the other direction too: sometimes a bare + lambda is the whole right answer. +- `bin/setup` should end by exec'ing this. Setup that verifies itself + is setup people trust; setup people trust never grows a wiki page. + +## Verdict + +Four rounds in, the pattern for me is one line long: the framework now +lets a small idea stay small. The doctor is 80 lines and half of them +are the actual checks — the framework's share of the file has become a +rounding error, which is where every framework should aspire to live. diff --git a/examples/setup_doctor.rb b/examples/setup_doctor.rb new file mode 100644 index 0000000..e2b3d19 --- /dev/null +++ b/examples/setup_doctor.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# The Setup Doctor: every onboarding wiki page is a bug. This runs the +# checks a README asks a new hire to do by hand - ruby version, bundle +# health, git state, test suite presence - in parallel, then one +# diagnosis task reads them all BY NAME and prescribes. +# +# bundle exec ruby examples/setup_doctor.rb +# +# Runs offline against the current repo. Exit 0 means "start coding". + +require_relative "../lib/agentic" + +ROOT = File.expand_path("..", __dir__) + +def check(description) + Agentic::Task.new( + description: description, + agent_spec: {"name" => description, "instructions" => "examine the machine"} + ) +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) + +ruby = check("ruby version") +orchestrator.add_task(ruby, agent: ->(_t) { + required = File.read(File.join(ROOT, "agentic.gemspec"))[/required_ruby_version.*?([\d.]+)/, 1] + {ok: Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(required), + detail: "running #{RUBY_VERSION}, gem requires >= #{required}"} +}) + +bundle = check("bundle health") +orchestrator.add_task(bundle, agent: ->(_t) { + ok = system("bundle check > /dev/null 2>&1", chdir: ROOT) + {ok: ok, detail: ok ? "all gems installed" : "run bin/setup (or bundle install)"} +}) + +git = check("git state") +orchestrator.add_task(git, agent: ->(_t) { + dirty = `git -C #{ROOT} status --porcelain`.lines.size + branch = `git -C #{ROOT} branch --show-current`.strip + {ok: true, detail: "on #{branch}, #{dirty} uncommitted change(s)"} +}) + +suite = check("test suite") +orchestrator.add_task(suite, agent: ->(_t) { + specs = Dir[File.join(ROOT, "spec", "**", "*_spec.rb")].size + {ok: specs.positive?, detail: "#{specs} spec files ready (bundle exec rake spec)"} +}) + +diagnosis = check("diagnosis") +orchestrator.add_task(diagnosis, needs: {ruby: ruby, bundle: bundle, git: git, suite: suite}, agent: ->(t) { + findings = { + "ruby" => t.needs.ruby, + "bundle" => t.needs.bundle, + "git" => t.needs.git, + "tests" => t.needs.suite + } + {healthy: findings.values.all? { |f| f[:ok] }, findings: findings} +}) + +result = orchestrator.execute_plan +verdict = result.results[diagnosis.id].output + +puts "SETUP DOCTOR" +puts +verdict[:findings].each do |name, finding| + puts format(" %s %-8s %s", finding[:ok] ? "ok " : "FIX", name, finding[:detail]) +end +puts +if verdict[:healthy] + puts "you're good. write code, not wiki pages." +else + puts "fix the FIX lines above, run me again." + exit 1 +end From 08fdeba8c239452c156c06e1146cb943a037574b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:52:07 +0000 Subject: [PATCH 38/46] docs: concurrency knee finder example (tenderlove round 4) Measures wall time and queue-wait (via the new task_slot_acquired hook) across concurrency limits and recommends where adding lanes stops paying - the long-pole flatline made visible. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/03-tenderlove.md | 58 ++++++++++++++++++++++ examples/knee_finder.rb | 56 +++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 docs/perspectives/round-4/03-tenderlove.md create mode 100644 examples/knee_finder.rb diff --git a/docs/perspectives/round-4/03-tenderlove.md b/docs/perspectives/round-4/03-tenderlove.md new file mode 100644 index 0000000..01fd5d6 --- /dev/null +++ b/docs/perspectives/round-4/03-tenderlove.md @@ -0,0 +1,58 @@ +# Round 4 field notes — Aaron Patterson finds the knee + +*Built: `examples/knee_finder.rb` — the same plan at seven concurrency +limits, measured with the `task_slot_acquired` hook, with a +recommendation for where adding lanes stops paying.* + +## What I built and why + +"What should `concurrency_limit` be?" is answered by superstition in +every codebase I've ever audited — someone typed 10 in 2019 and it +became scripture. The knee finder replaces the scripture with a +measurement: run the workload at 1, 2, 3, 4, 6, 8, 12; record wall time +and — new this round — **total queue-wait**, straight from the +`task_slot_acquired` hook I asked for; recommend the smallest limit +within 15% of the best wall time. + +``` +limit wall total queue-wait + 1 1205ms 6790ms + 4 350ms 1001ms + 6 300ms 470ms <- knee + 8 300ms 230ms + 12 300ms 0ms +recommendation: concurrency_limit 6 +``` + +Wall time flatlines at 300ms from limit 6 onward — because one call in +the workload takes 300ms, and **you cannot fan out a long pole**. Limits +8 and 12 buy zero wall time; they only buy down queue-wait, which is +invisible to your user and costs you open connections. That flatline is +the single most useful line in the chart, and it's exactly what the old +hooks couldn't show: without slot-acquisition timestamps, queue-wait and +run-time were smeared into one number and the knee was unfindable. + +## Confession, as tradition requires + +My first draft's workload had uniform latencies and the "knee" came out +at the maximum — a straight diagonal, recommendation useless. Real +workloads have a dominant slow call (there is *always* a slow call), and +the moment I added one, the curve grew its knee. Benchmarks that don't +model the long pole recommend infinity. This is the benchmark version of +regexes-vs-parsers: model the thing that actually dominates or your +tool confidently answers the wrong question. + +## Framework notes + +- The hook composes: `queue_wait += waited` is the entire integration. + One float closure, no instrumentation framework. Hooks that run + inline (now documented!) make accumulation this cheap safe. +- Gantt + knee finder are now a pair: the Gantt shows you *where* one + run's time went ('.' vs '#'); the knee finder shows you *how the + budget moves* across limits. Ship both in a `agentic-doctor` gem and + ops people will send you fruit baskets. + +## Verdict + +Asked for a hook in round 3, used it to kill a superstition in round 4. +That's the feedback loop working at the speed it should. diff --git a/examples/knee_finder.rb b/examples/knee_finder.rb new file mode 100644 index 0000000..2343e00 --- /dev/null +++ b/examples/knee_finder.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# The Knee Finder: runs the same plan at increasing concurrency limits, +# measures wall time and total queue-wait via the task_slot_acquired +# hook, and recommends the limit where adding lanes stops paying. +# Guessing concurrency limits is a superstition; this is a measurement. +# +# bundle exec ruby examples/knee_finder.rb +# +# Runs offline; the workload is 12 simulated API calls of mixed latency. + +require_relative "../lib/agentic" + +# One slow call dominates, as it always does in production +LATENCIES = [0.08, 0.05, 0.12, 0.06, 0.30, 0.05, 0.15, 0.07, 0.05, 0.09, 0.06, 0.12].freeze + +def run_at(limit) + queue_wait = 0.0 + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: limit, + lifecycle_hooks: { + task_slot_acquired: ->(task_id:, task:, waited:) { queue_wait += waited } + } + ) + + LATENCIES.each_with_index do |latency, i| + orchestrator.add_task(Agentic::Task.new( + description: "call-#{i}", + agent_spec: {"name" => "api", "instructions" => "wait"}, + payload: latency + ), agent: ->(t) { sleep(t.payload) || :ok }) + end + + result = orchestrator.execute_plan + {wall: result.execution_time, queue_wait: queue_wait} +end + +puts "KNEE FINDER: #{LATENCIES.size} calls, #{(LATENCIES.sum * 1000).round}ms of total IO" +puts +puts " limit wall total queue-wait" + +measurements = [1, 2, 3, 4, 6, 8, 12].to_h do |limit| + m = run_at(limit) + bar = "#" * (m[:wall] * 40).round + puts format(" %5d %4dms %6dms %s", limit, m[:wall] * 1000, m[:queue_wait] * 1000, bar) + [limit, m] +end + +# The knee: smallest limit whose wall time is within 15% of the best +best = measurements.values.map { |m| m[:wall] }.min +knee = measurements.find { |_, m| m[:wall] <= best * 1.15 }.first + +puts +puts " recommendation: concurrency_limit #{knee}" +puts " (smallest limit within 15% of the best wall time - beyond it you" +puts " hold more connections open to save less time than the jitter)" From b0d140fd08d7aaf5f72789f784c59b812b53d687 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:53:44 +0000 Subject: [PATCH 39/46] docs: coupling cartographer example (fxn round 4) Constant-reference graph between files via parallel Prism surveys and a fan-in atlas: load-bearing walls, heaviest leaners, and mutual dependencies. Field notes on namespace-relative constant resolution and the default-proc trap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/04-fxn.md | 58 +++++++++++++++ examples/coupling_cartographer.rb | 110 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/perspectives/round-4/04-fxn.md create mode 100644 examples/coupling_cartographer.rb diff --git a/docs/perspectives/round-4/04-fxn.md b/docs/perspectives/round-4/04-fxn.md new file mode 100644 index 0000000..379fd36 --- /dev/null +++ b/docs/perspectives/round-4/04-fxn.md @@ -0,0 +1,58 @@ +# Round 4 field notes — Xavier Noria charts the coupling + +*Built: `examples/coupling_cartographer.rb` — a constant-reference +graph between files: who defines what, who references it, which walls +bear load, which files lean hardest, and whether any pair leans on +each other.* + +## What I built and why + +Rounds 2 and 3 mapped names and prose; round 4 maps *forces*. Each file +is surveyed (Prism, in parallel) for constants defined and constants +referenced; the atlas task joins the two sides into a directed graph. +Ownership is resolved by trailing segment, because inside `module +Agentic` a reference reads `LlmClient`, not `Agentic::LlmClient` — +the survey must resolve constants the way Ruby does, relative to the +namespace you stand in, or the map measures a language that doesn't +exist. + +Findings for this gem: + +- **Load-bearing walls**: `llm_config.rb` (8 dependents) and + `errors.rb` (6). Both are leaf-like value/constant definitions — + exactly what you want at the bottom of a dependency graph. A change + to either is a change to a public commitment; their test coverage + should match their in-degree. +- **Heaviest leaners**: `cli.rb` at 15 — unsurprising and fine (a CLI + is a terminus; nothing leans back on it), and `agentic.rb` at 12, + which is the entry point doing entry-point things. +- **One mutual dependency**: `agentic.rb <-> llm_client.rb`. True and + known — the module owns configuration, the client reads it, the + module exposes `Agentic.client`. Mutual edges at the entry point are + tolerable; mutual edges between two mid-level files would be a + design smell. The atlas found exactly one, at the tolerable spot, + and none elsewhere: a genuinely clean graph. + +## The bug I wrote, in the spirit of full disclosure + +`Hash.new { |h, k| h[k] = [] }` leaked out of the builder into the +reader, and the mutual-dependency probe — merely *asking* whether a +file had edges — **invented** empty edge lists mid-iteration: +"can't add a new key into hash during iteration." A default proc is a +constructor's convenience and a reader's trap; copy it away at the +boundary. Loaders taught me this same lesson years ago: mutation +behind an innocent-looking read is where the strangest bugs live +(`const_missing`, anyone?). + +## Framework note + +The 65-way fan-in atlas is by now routine — third round in a row this +shape appears (digest, doc coverage, now this). It's the framework's +signature move: parallel facts, one joining task. The pattern deserves +a name in the docs. I propose *survey/atlas*. + +## Verdict + +The map shows a gem whose load flows downward onto small, stable +files, with one honest cycle at the front door. Cartography's highest +compliment: nothing surprising, now with evidence. diff --git a/examples/coupling_cartographer.rb b/examples/coupling_cartographer.rb new file mode 100644 index 0000000..78e300b --- /dev/null +++ b/examples/coupling_cartographer.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# The Coupling Cartographer: which files lean on which? Every file is +# surveyed for the constants it DEFINES and the constants it REFERENCES; +# a fan-in task joins the two into a dependency graph and reports the +# load-bearing walls and the heaviest leaners. +# +# bundle exec ruby examples/coupling_cartographer.rb [lib_dir] +# +# Runs offline; Prism supplies both sides of every edge. + +require_relative "../lib/agentic" +require "prism" + +LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") + +# Collects constants defined and referenced in one parse tree +def survey_constants(node, namespace, defined, referenced) + return unless node + + case node + when Prism::ModuleNode, Prism::ClassNode + name = node.constant_path.slice + full = [namespace, name].reject(&:empty?).join("::") + defined << full + node.child_nodes.each { |child| survey_constants(child, full, defined, referenced) } + return + when Prism::ConstantReadNode + referenced << node.name.to_s + when Prism::ConstantPathNode + referenced << node.slice + end + + node.child_nodes.each { |child| survey_constants(child, namespace, defined, referenced) } +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) +files = Dir[File.join(LIB, "**", "*.rb")].sort + +surveys = files.map do |path| + task = Agentic::Task.new( + description: path.delete_prefix("#{LIB}/"), + agent_spec: {"name" => "Surveyor", "instructions" => "chart the constants"}, + payload: path + ) + orchestrator.add_task(task, agent: ->(t) { + defined = [] + referenced = [] + survey_constants(Prism.parse_file(t.payload).value, "", defined, referenced) + {defined: defined, referenced: referenced.uniq - defined} + }) + task +end + +atlas = Agentic::Task.new( + description: "the atlas", + agent_spec: {"name" => "Cartographer", "instructions" => "join the maps"} +) +orchestrator.add_task(atlas, surveys, agent: ->(t) { + charts = surveys.to_h { |s| [s.description, t.output_of(s)] } + + # Who owns each constant (by trailing segment, since references are + # often relative: LlmClient rather than Agentic::LlmClient) + owners = {} + charts.each do |file, chart| + chart[:defined].each { |const| owners[const.split("::").last] = file } + end + + edges = Hash.new { |h, k| h[k] = [] } + charts.each do |file, chart| + chart[:referenced].each do |ref| + owner = owners[ref.split("::").last] + edges[file] << owner if owner && owner != file + end + end + # Copy without the default proc: a Hash.new {} that leaks to readers + # invents keys on every miss - including during iteration + edges = edges.transform_values(&:uniq) + + inbound = Hash.new(0) + edges.each_value { |targets| targets.each { |target| inbound[target] += 1 } } + + {edges: edges, inbound: inbound} +}) + +result = orchestrator.execute_plan +atlas_data = result.results[atlas.id].output + +puts "COUPLING ATLAS of #{LIB} (#{files.size} files)" +puts +puts "load-bearing walls (most depended-upon):" +atlas_data[:inbound].sort_by { |_, count| -count }.first(6).each do |file, count| + puts format(" %2d files lean on %s", count, file) +end +puts +puts "heaviest leaners (most dependencies out):" +atlas_data[:edges].sort_by { |_, targets| -targets.size }.first(6).each do |file, targets| + puts format(" %-40s leans on %2d files", file, targets.size) +end + +mutual = atlas_data[:edges].flat_map { |file, targets| + targets.filter_map { |target| [file, target].sort if atlas_data[:edges][target]&.include?(file) } +}.uniq +puts +if mutual.empty? + puts "no mutual dependencies - every edge points one way. rare, and good." +else + puts "mutual dependencies (each file references the other):" + mutual.each { |a, b| puts " #{a} <-> #{b}" } +end From 4b66c0d822196c7ef08ef6c82cd89c908dbad692 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:55:23 +0000 Subject: [PATCH 40/46] docs: shared rate limit example (ioquatix round 4) Two concurrent plans in one reactor bounded by a single credential-scoped Async::Semaphore; the high-water mark proves the ceiling holds across plan boundaries while calls interleave freely. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/05-ioquatix.md | 62 ++++++++++++++++ examples/shared_rate_limit.rb | 93 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/perspectives/round-4/05-ioquatix.md create mode 100644 examples/shared_rate_limit.rb diff --git a/docs/perspectives/round-4/05-ioquatix.md b/docs/perspectives/round-4/05-ioquatix.md new file mode 100644 index 0000000..21d1c8c --- /dev/null +++ b/docs/perspectives/round-4/05-ioquatix.md @@ -0,0 +1,62 @@ +# Round 4 field notes — Samuel Williams shares the rate limit + +*Built: `examples/shared_rate_limit.rb` — two plans in one reactor, +one credential-scoped semaphore, ceiling held at 3 in-flight across +both.* + +## What I built and why + +My round-3 note said the thing you actually want to bound is usually +per-*credential*, not per-orchestrator: one OpenAI key means one rate +limit shared by every plan in the process. This round I built it in +userland to prove the primitives suffice: a `RateLimitedApi` owning an +`Async::Semaphore(3)`, handed to two orchestrators that each *think* +they're allowed 10 concurrent tasks. The run is the argument: + +``` +plan 1: completed, 8 tasks +plan 2: completed, 8 tasks +in-flight high-water mark: 3 (ceiling 3) - held +calls interleaved across plans: yes +``` + +Sixteen calls, both plans finishing, calls interleaving freely across +plan boundaries — and never more than three in flight. **Rate limits +belong to the resource, so the semaphore lives with the resource.** +The orchestrator's `concurrency_limit` is a scheduling policy; the +credential's ceiling is a law of physics. Two different numbers, two +different owners, and the design falls out correctly the moment you +ask who owns each. + +## Structured-concurrency notes + +- Both plans run as sibling `Async` tasks under one `Sync` — three + rounds of composability work (`Sync` in execute_plan, the barrier + spawn fix) are what make "two orchestrators in one reactor" a + two-line expression instead of a threading design document. +- The semaphore + fiber scheduler interaction is doing quiet heavy + lifting: a task blocked on the credential's semaphore yields its + *orchestrator* slot's fiber but not the reactor — sibling tasks from + the other plan run through the gap. That's why the interleaving + check passes; a thread-per-task design would show convoy effects + here. +- The high-water mark is the honest metric. Don't assert "the + semaphore works" — count concurrent entries and report the max. Any + future refactor that breaks the ceiling turns "held" into + "BREACHED" in the output, which makes this example its own + regression test. + +## For the maintainers + +This pattern is one small class away from being a feature: +`Agentic::RateLimit.new(ceiling)` that an `LlmClient` (or any agent) +wraps its calls in, shareable across plans. The example is the design +document; the class is an afternoon. I'd also accept +"`LlmClient` accepts a `limiter:`" as the minimal version. + +## Verdict + +Per-credential rate limiting: asked for in round 3, demonstrated from +primitives in round 4, one high-water mark from being a feature. The +reactor did exactly what structured concurrency promises — nothing +surprising happened, measurably. diff --git a/examples/shared_rate_limit.rb b/examples/shared_rate_limit.rb new file mode 100644 index 0000000..085137e --- /dev/null +++ b/examples/shared_rate_limit.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# The Shared Rate Limit: two plans run concurrently in one reactor, but +# the API key they share allows only 3 requests in flight. A single +# Async::Semaphore, passed to both, enforces the provider's ceiling +# across plan boundaries - because rate limits belong to credentials, +# not to orchestrators. +# +# bundle exec ruby examples/shared_rate_limit.rb +# +# Runs offline; the proof is the high-water mark. + +require_relative "../lib/agentic" +require "async" +require "async/semaphore" + +API_CEILING = 3 + +# The shared credential: a semaphore plus a high-water-mark counter +class RateLimitedApi + attr_reader :high_water + + def initialize(ceiling) + @semaphore = Async::Semaphore.new(ceiling) + @in_flight = 0 + @high_water = 0 + @calls = [] + end + + def call(plan, name, latency) + @semaphore.acquire do + @in_flight += 1 + @high_water = [@high_water, @in_flight].max + @calls << "#{plan}/#{name}" + sleep(latency) + @in_flight -= 1 + "#{name}:ok" + end + end + + def interleaved? + plans = @calls.map { |c| c.split("/").first } + plans.uniq.size > 1 && plans != plans.sort + end +end + +def build_plan(label, task_count, api) + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 10) + task_count.times do |i| + orchestrator.add_task(Agentic::Task.new( + description: "#{label}-#{i}", + agent_spec: {"name" => label, "instructions" => "call the API"}, + payload: 0.04 + (i % 3) * 0.02 + ), agent: ->(t) { api.call(label, t.description, t.payload) }) + end + orchestrator +end + +api = RateLimitedApi.new(API_CEILING) + +puts "SHARED RATE LIMIT: two plans, one credential, ceiling #{API_CEILING}" +puts + +wall = nil +Sync do + ingest = build_plan("ingest", 8, api) + enrich = build_plan("enrich", 8, api) + + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + # Both plans run as siblings in this reactor; each would happily use + # 10 slots, but the shared semaphore is the credential's law + plans = [ingest, enrich].map do |orchestrator| + Async { orchestrator.execute_plan } + end + results = plans.map(&:wait) + wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + results.each_with_index do |result, i| + puts format(" plan %d: %s, %d tasks", i + 1, result.status, + result.results.count { |_, r| r.successful? }) + end +end + +puts +puts format(" wall time: %dms for 16 calls of ~60ms each", wall * 1000) +puts format(" in-flight high-water mark: %d (ceiling %d) %s", + api.high_water, API_CEILING, (api.high_water <= API_CEILING) ? "- held" : "- BREACHED") +puts " calls interleaved across plans: #{api.interleaved? ? "yes" : "no"}" +puts +puts "each orchestrator had concurrency_limit 10; the credential said 3." +puts "the credential won, across both plans, because the semaphore lives" +puts "with the resource it protects - not with either scheduler." From 294aa542167a62554706e02ac67d492a60bdb781 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:57:36 +0000 Subject: [PATCH 41/46] fix: canceled plans no longer report :completed; add invariant sentinel example overall_status never consulted the :canceled state, so a canceled plan with no failures reported itself complete. Canceled tasks now yield :canceled. Found by examples/invariant_sentinel.rb (Jeremy Evans round 4), which checks domain invariants after every task via hooks and arrests the plan at the first broken law. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/06-jeremyevans.md | 60 ++++++++++++ examples/invariant_sentinel.rb | 100 ++++++++++++++++++++ lib/agentic/plan_orchestrator.rb | 4 + spec/agentic/round4_features_spec.rb | 19 ++++ 4 files changed, 183 insertions(+) create mode 100644 docs/perspectives/round-4/06-jeremyevans.md create mode 100644 examples/invariant_sentinel.rb diff --git a/docs/perspectives/round-4/06-jeremyevans.md b/docs/perspectives/round-4/06-jeremyevans.md new file mode 100644 index 0000000..1877fcb --- /dev/null +++ b/docs/perspectives/round-4/06-jeremyevans.md @@ -0,0 +1,60 @@ +# Round 4 field notes — Jeremy Evans posts the invariant sentinel + +*Built: `examples/invariant_sentinel.rb` — domain invariants checked +after every task from a lifecycle hook; a seeded off-by-one is caught +at the task that caused it, and the plan stops. Also fixed the plan +status lying about cancellation.* + +## What I built and why + +Validation checks data at boundaries; invariants check the *world* +between steps. The sentinel is a hook that runs every declared law +after every task: stock never negative, stock always equals initial + +received − picked. One picker in the job list decrements 3 while +recording 2 — the accounting bug every warehouse system eventually +writes — and the run shows the payoff: + +``` +LAW BROKEN: "stock equals initial + received - picked" + by: pick 2 widgets (buggy picker) +jobs completed before the stop: 3 of 4 +``` + +The corrupting task is named, the world state at the moment of arrest +is printed, and the fourth job never ran. Corruption caught at the +task that caused it is a bug report; corruption found at month-end +close is an incident with a conference call. The second invariant is +the one that fired, note — the *conservation law*, not the obvious +"never negative" check. Cheap invariants catch crashes; conservation +invariants catch lies. + +## The framework bug this flushed out + +My first run printed `plan status: completed` — for a plan the +sentinel had just *canceled*. `overall_status` checked failed, pending, +and in-progress states but never consulted `:canceled`, so a canceled +plan with no failures reported itself complete. That is a status API +telling a comforting falsehood, which is worse than no status API. +Fixed in the framework (canceled tasks → `:canceled`), regression +spec added. Sentinels that watch the watchers: this is the third +round in a row where a persona's example found a defect the suite +missed, and the pattern is consistent — **suites test what authors +imagined; examples test what users do.** + +## Design notes + +- `concurrency_limit: 1` is load-bearing: with parallel jobs, "which + task broke the law" becomes probabilistic. Determinism first, then + speed — an auditor that sometimes names the wrong suspect is worse + than a slow one. +- The state snapshot uses `Marshal` deep-copy at arrest time, because + an evidence photo that mutates after the arrest is not evidence. +- These invariants are lambdas over global state for demo brevity; in + production they'd be queries over your actual store. The pattern is + the hook placement, not the storage. + +## Verdict + +Two laws, four jobs, one arrest, one framework fix. Invariant checking +from hooks costs a dozen lines and converts a class of month-end +incidents into same-second bug reports. Post the sentinel. diff --git a/examples/invariant_sentinel.rb b/examples/invariant_sentinel.rb new file mode 100644 index 0000000..879e606 --- /dev/null +++ b/examples/invariant_sentinel.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# The Invariant Sentinel: domain invariants checked after EVERY task, +# from a lifecycle hook. When a task leaves the world in an illegal +# state, the sentinel names the task, names the broken law, and stops +# the plan before the corruption compounds. One of the pickers below +# has an off-by-one; watch how far it gets. +# +# bundle exec ruby examples/invariant_sentinel.rb +# +# Runs offline and deterministically. + +require_relative "../lib/agentic" + +WAREHOUSE = {stock: {"widget" => 10, "gadget" => 8}, received: {}, picked: {}} + +INVARIANTS = { + "stock is never negative" => -> { + WAREHOUSE[:stock].values.all? { |count| count >= 0 } + }, + "stock equals initial + received - picked" => -> { + WAREHOUSE[:stock].all? do |sku, count| + initial = {"widget" => 10, "gadget" => 8}.fetch(sku) + count == initial + WAREHOUSE[:received].fetch(sku, 0) - WAREHOUSE[:picked].fetch(sku, 0) + end + } +}.freeze + +JOBS = [ + {name: "receive 5 widgets", work: -> { + WAREHOUSE[:stock]["widget"] += 5 + WAREHOUSE[:received]["widget"] = WAREHOUSE[:received].fetch("widget", 0) + 5 + }}, + {name: "pick 3 gadgets", work: -> { + WAREHOUSE[:stock]["gadget"] -= 3 + WAREHOUSE[:picked]["gadget"] = WAREHOUSE[:picked].fetch("gadget", 0) + 3 + }}, + {name: "pick 2 widgets (buggy picker)", work: -> { + WAREHOUSE[:stock]["widget"] -= 3 # decrements 3, records 2: the bug + WAREHOUSE[:picked]["widget"] = WAREHOUSE[:picked].fetch("widget", 0) + 2 + }}, + {name: "receive 4 gadgets", work: -> { + WAREHOUSE[:stock]["gadget"] += 4 + WAREHOUSE[:received]["gadget"] = WAREHOUSE[:received].fetch("gadget", 0) + 4 + }} +].freeze + +violations = [] +orchestrator = nil + +sentinel = lambda do |task_id:, task:, result:, duration:| + INVARIANTS.each do |law, check| + next if check.call + + violations << {task: task.description, law: law, state: Marshal.load(Marshal.dump(WAREHOUSE))} + orchestrator.cancel_plan + end +end + +orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, # deterministic order so the culprit is unambiguous + lifecycle_hooks: {after_task_success: sentinel} +) + +previous = nil +JOBS.each do |job| + task = Agentic::Task.new( + description: job[:name], + agent_spec: {"name" => "warehouse", "instructions" => "do the job"}, + payload: job[:work] + ) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { t.payload.call || :done }) + previous = task +end + +result = orchestrator.execute_plan + +puts "INVARIANT SENTINEL: #{INVARIANTS.size} laws watching #{JOBS.size} jobs" +puts +puts "plan status: #{result.status}" +completed = result.results.values.count(&:successful?) +puts "jobs completed before the stop: #{completed} of #{JOBS.size}" +puts + +if violations.empty? + puts "every law held. suspiciously well-behaved." +else + violations.each do |violation| + puts "LAW BROKEN: \"#{violation[:law]}\"" + puts " by: #{violation[:task]}" + puts " world state at the moment of arrest:" + puts " stock: #{violation[:state][:stock]}" + puts " received: #{violation[:state][:received]}" + puts " picked: #{violation[:state][:picked]}" + end + puts + puts "the plan stopped at the FIRST broken law - 'receive 4 gadgets'" + puts "never ran. corruption caught at the task that caused it is a" + puts "bug report; corruption found at month-end close is an incident." +end diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index b7d7ec7..090b4d7 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -278,6 +278,10 @@ def find_eligible_tasks def overall_status if @execution_state[:failed].any? :partial_failure + elsif @execution_state[:canceled].any? + # A plan with canceled tasks did not complete, even if every task + # that ran succeeded + :canceled elsif @execution_state[:pending].empty? && @execution_state[:in_progress].empty? :completed else diff --git a/spec/agentic/round4_features_spec.rb b/spec/agentic/round4_features_spec.rb index 40f97bc..3fecb82 100644 --- a/spec/agentic/round4_features_spec.rb +++ b/spec/agentic/round4_features_spec.rb @@ -134,6 +134,25 @@ def retryable? = true end end + describe "overall status of canceled plans" do + it "reports :canceled instead of :completed when tasks were canceled" do + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, + lifecycle_hooks: { + after_task_success: ->(task_id:, task:, result:, duration:) { orchestrator.cancel_plan } + } + ) + first = task_named("first") + second = task_named("never runs") + orchestrator.add_task(first, agent: ->(_t) { :ok }) + orchestrator.add_task(second, [first], agent: ->(_t) { :ok }) + + result = orchestrator.execute_plan + + expect(result.status).to eq(:canceled) + end + end + describe "contract value predicates" do let(:specification) do Agentic::CapabilitySpecification.new( From 27824f3194357aa9a0a9c776b6c144d9e2dd2c37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:58:43 +0000 Subject: [PATCH 42/46] docs: contract state machine example (solnic round 4) Order lifecycle where transition guards are enum predicates on capability contracts - no runtime transition table, illegal moves are type errors naming the legal alternatives, and output enums keep transitions honest about their destination. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/07-solnic.md | 59 ++++++++++++++++++ examples/state_machine.rb | 86 ++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 docs/perspectives/round-4/07-solnic.md create mode 100644 examples/state_machine.rb diff --git a/docs/perspectives/round-4/07-solnic.md b/docs/perspectives/round-4/07-solnic.md new file mode 100644 index 0000000..e83ee13 --- /dev/null +++ b/docs/perspectives/round-4/07-solnic.md @@ -0,0 +1,59 @@ +# Round 4 field notes — Piotr Solnica types the state machine + +*Built: `examples/state_machine.rb` — an order lifecycle where every +transition's guard is an enum predicate on its contract, not an +if-statement.* + +## What I built and why + +Round 3 closed with Jeremy and me asking for value predicates — enums, +bounds, non-empty. They shipped, so I built the structure that +predicates make possible: a state machine with **no runtime transition +table**. Each event is a capability; its contract declares +`state: {enum: %w[placed]}` as the legal source states; its output +contract declares the destination. The topology of the machine lives +entirely in the type layer: + +``` +deliver XX cannot deliver from 'cart' (legal from: shipped) +place -> now 'placed' +cancel XX cannot cancel from 'shipped' (legal from: cart, placed) +journey: cart -> placed -> shipped -> delivered +``` + +An illegal move isn't a branch that returns false — it's input that +*never type-checks*, and the violation arrives with the legal +alternatives attached. Guards-as-contracts means the machine's `fire` +method contains zero domain logic: look up, execute, rescue. Every +state machine library you've used is a DSL for generating exactly the +checks these contracts now express declaratively. + +## The detail I'm most pleased by + +The **output** enum: each transition declares +`outputs: {state: {enum: [rule[:to]]}}` — a single-element enum, i.e. +"this transition produces exactly this state." While iterating I fat- +fingered a rule to return the event name instead of the target state, +and the *output* contract caught my own machine misbehaving before any +test did. Transitions that can't lie about where they land are the +difference between a state machine and a state suggestion. + +## What this exposes about the seam + +- Enum violations report `state violated` with dry-schema's default + message. Serviceable, but the *legal values* live in my rescue block, + reconstructed from the transition table. The violation payload should + carry the predicate's expectation (`included_in?: [...]`) so callers + don't need side-channel knowledge to render a good error. Small + addition to `ValidationError`, big ergonomic win. +- Missing predicate, noted for round 5: cross-field constraints + ("`express` shipping requires `quantity <= 10`"). Single-key + predicates cover 80%; the remaining 20% is where dry-validation's + rules (not just dry-schema) would enter. + +## Verdict + +Asked for predicates in round 3; in round 4 they replaced an entire +category of control flow. That's the test of a type-layer feature — +not "can it reject bad data" but "what code does it delete." Here it +deleted the case statement every state machine is built on. diff --git a/examples/state_machine.rb b/examples/state_machine.rb new file mode 100644 index 0000000..2d114d2 --- /dev/null +++ b/examples/state_machine.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# The Contract State Machine: each transition is a capability whose +# guard is not an if-statement but an enum predicate on its declared +# contract (new this round). An illegal transition doesn't fail - it +# never types-checks in the first place, and the violation names the +# states that WOULD have been legal. +# +# bundle exec ruby examples/state_machine.rb +# +# Runs offline. Watch "deliver" bounce off a cart-state order. + +require_relative "../lib/agentic" + +# from: is the transition guard, expressed as a contract enum +TRANSITIONS = { + "place" => {from: %w[cart], to: "placed"}, + "ship" => {from: %w[placed], to: "shipped"}, + "deliver" => {from: %w[shipped], to: "delivered"}, + "cancel" => {from: %w[cart placed], to: "canceled"} +}.freeze + +TRANSITIONS.each do |event, rule| + spec = Agentic::CapabilitySpecification.new( + name: event, + description: "Transition an order via #{event}", + version: "1.0.0", + inputs: { + order_id: {type: "string", required: true, non_empty: true}, + state: {type: "string", required: true, enum: rule[:from]} + }, + outputs: {state: {type: "string", required: true, enum: [rule[:to]]}} + ) + Agentic.register_capability(spec, Agentic::CapabilityProvider.new( + capability: spec, + implementation: ->(inputs) { {state: rule[:to]} } + )) +end + +# The machine: current state + registry lookup. No case statement, +# no transition table at runtime - the contracts ARE the table. +class Order + attr_reader :id, :state, :history + + def initialize(id) + @id = id + @state = "cart" + @history = ["cart"] + end + + def fire(event) + provider = Agentic::AgentCapabilityRegistry.instance.get_provider(event) or + return [:unknown_event, event] + + result = provider.execute(order_id: @id, state: @state) + @state = result[:state] + @history << @state + [:ok, @state] + rescue Agentic::Errors::ValidationError => e + allowed = TRANSITIONS.fetch(event)[:from] + [:illegal, "cannot #{event} from '#{@state}' (legal from: #{allowed.join(", ")}) - #{e.violations.keys.join(", ")} violated"] + end +end + +order = Order.new("ord-7") + +SCRIPT = %w[deliver place place ship cancel deliver].freeze + +puts "CONTRACT STATE MACHINE: order #{order.id} begins in 'cart'" +puts +SCRIPT.each do |event| + verdict, detail = order.fire(event) + case verdict + when :ok then puts format(" %-8s -> now '%s'", event, detail) + when :illegal then puts format(" %-8s XX %s", event, detail) + when :unknown_event then puts format(" %-8s ?? no such transition", event) + end +end + +puts +puts "journey: #{order.history.join(" -> ")}" +puts +puts "the machine has no case statement and no runtime transition table:" +puts "each event's contract declares its legal source states as an enum," +puts "and the validator enforces the topology. illegal moves are type" +puts "errors with the legal alternatives in the message." From 608f2a41619b91faa22910713362b77248591f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:59:56 +0000 Subject: [PATCH 43/46] docs: error taxonomy drill example (mperham round 4) Three failure modes under one retry policy: the rate limit's own retryable? verdict earns retries, the auth error's verdict overrules a deliberately-misconfigured type list, and opinion-less errors fall back to the list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/08-mperham.md | 63 ++++++++++++++++++++ examples/error_taxonomy_drill.rb | 79 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/perspectives/round-4/08-mperham.md create mode 100644 examples/error_taxonomy_drill.rb diff --git a/docs/perspectives/round-4/08-mperham.md b/docs/perspectives/round-4/08-mperham.md new file mode 100644 index 0000000..c55c1cb --- /dev/null +++ b/docs/perspectives/round-4/08-mperham.md @@ -0,0 +1,63 @@ +# Round 4 field notes — Mike Perham drills the error taxonomy + +*Built: `examples/error_taxonomy_drill.rb` — three failure modes, one +retry policy, three correct outcomes, because errors now testify about +their own retryability.* + +## What I built and why + +My round-3 note said the retry policy should consult +`failure.retryable?` — the gem's own error taxonomy +(`LlmRateLimitError#retryable? => true`, +`LlmAuthenticationError#retryable? => false`) was sitting there being +ignored by string matching on class names. It shipped in the roadmap +release, so the drill exercises the whole decision tree at once: + +``` +OK rate-limited sync 3 attempt(s) synced on attempt 3 +DEAD bad-credentials sync 1 attempt(s) gave up: 401 key revoked +OK mystery-error sync 2 attempt(s) recovered on attempt 2 +``` + +The middle line is the one I built this for. I *deliberately* put +`LlmAuthenticationError` in the policy's `retryable_errors` list — the +kind of config mistake that happens in every ops team ("just add it to +the list, it'll retry") — and the error's own verdict overruled it. +**One attempt.** A revoked key does not improve with persistence, and +now the object that knows that gets the final word. The type list +still earns its keep as the fallback for errors with no opinion (the +mystery `RuntimeError` got its second chance from the list). + +## The hierarchy of authority, spelled out + +1. `max_retries` — the budget, absolute. +2. The error's own `retryable?` — the domain verdict, when offered. +3. The policy's type list — the operator's fallback, for errors that + don't testify. + +That ordering matters. Reversed (list over verdict), config mistakes +would override domain knowledge; today's drill *is* that mistake, and +the framework survived it. Retry systems fail through their config +more often than their code — good ones make the config hard to hold +wrong. + +## Notes + +- All three drills ran concurrently under one policy — retryability is + per-failure, not per-plan, which is the only granularity that + survives real workloads (your plan talks to three APIs with three + temperaments). +- `plan: partial_failure` is honest: one task is dead and the plan + says so. Between this and Jeremy's `:canceled` fix, the status enum + finally covers what actually happens to plans. +- Still open from round 3: jitter defaults off. Two hundred workers + retrying a rate limit on the same constant schedule is a + synchronized second stampede. I'll keep saying it until it's the + default. + +## Verdict + +Asked in round 3, shipped in the release, drilled in round 4: errors +carry their own retry wisdom and the policy defers to it. The config +mistake I planted on purpose couldn't hurt anyone. That's what mature +retry machinery looks like. diff --git a/examples/error_taxonomy_drill.rb b/examples/error_taxonomy_drill.rb new file mode 100644 index 0000000..1d4a400 --- /dev/null +++ b/examples/error_taxonomy_drill.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# The Error Taxonomy Drill: three tasks fail three different ways - +# a rate limit (retryable, says the error itself), an auth failure +# (not retryable, says the error itself), and a mystery error (no +# opinion, so the policy's type list decides). One retry policy, +# three correct outcomes, because errors now testify. +# +# bundle exec ruby examples/error_taxonomy_drill.rb +# +# Runs offline and deterministically. + +require_relative "../lib/agentic" + +attempts = Hash.new(0) + +drills = { + "rate-limited sync" => lambda { |task| + attempts[task.description] += 1 + if attempts[task.description] < 3 + raise Agentic::Errors::LlmRateLimitError.new("429 slow down", retry_after: 1) + end + "synced on attempt #{attempts[task.description]}" + }, + "bad-credentials sync" => lambda { |task| + attempts[task.description] += 1 + raise Agentic::Errors::LlmAuthenticationError.new("401 key revoked") + }, + "mystery-error sync" => lambda { |task| + attempts[task.description] += 1 + raise "something vague" if attempts[task.description] < 2 + "recovered on attempt #{attempts[task.description]}" + } +} + +orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 3, + retry_policy: { + max_retries: 3, + backoff_strategy: :constant, + backoff_constant: 0.02, + # The type list is the fallback for errors with no opinion. + # RuntimeError is listed; the auth error's own verdict will overrule + # any list. That's the point. + retryable_errors: ["RuntimeError", "Agentic::Errors::LlmAuthenticationError"] + } +) + +tasks = drills.map do |name, drill| + task = Agentic::Task.new( + description: name, + agent_spec: {"name" => name, "instructions" => "call the API"}, + payload: drill + ) + orchestrator.add_task(task, agent: ->(t) { t.payload.call(t) }) + task +end + +result = orchestrator.execute_plan + +puts "ERROR TAXONOMY DRILL (max 3 retries for everyone)" +puts +tasks.each do |task| + task_result = result.results[task.id] + outcome = task_result.successful? ? task_result.output : "gave up: #{task_result.failure.message}" + verdict = task_result.successful? ? "OK " : "DEAD" + puts format(" %s %-22s %d attempt(s) %s", verdict, task.description, attempts[task.description], outcome) +end + +puts +puts "plan: #{result.status}" +puts +puts "why each outcome is right:" +puts " - the rate limit said retryable? -> true: retried until it cleared" +puts " - the auth error said retryable? -> false: ONE attempt, even though" +puts " someone unwisely put it in the retryable_errors list. a revoked" +puts " key does not improve with persistence; the error knew that" +puts " - the mystery RuntimeError had no opinion: the policy's type list" +puts " decided, and it earned its second chance" From c68fc40f5e70324f576666865c892d0028fe5e91 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:01:13 +0000 Subject: [PATCH 44/46] docs: graph critic example (Sandi Metz round 4) Pre-execution design review of dependency graphs: god tasks, deep chains, orphans - with one prescribed first move. Its best finding is the missing read-only graph accessor three tools have now had to crowbar around. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-4/09-sandimetz.md | 51 +++++++++++++ examples/graph_critic.rb | 91 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/perspectives/round-4/09-sandimetz.md create mode 100644 examples/graph_critic.rb diff --git a/docs/perspectives/round-4/09-sandimetz.md b/docs/perspectives/round-4/09-sandimetz.md new file mode 100644 index 0000000..d723050 --- /dev/null +++ b/docs/perspectives/round-4/09-sandimetz.md @@ -0,0 +1,51 @@ +# Round 4 field notes — Sandi Metz critiques the graph + +*Built: `examples/graph_critic.rb` — a design review for dependency +graphs, run before a single task executes.* + +## What I built and why + +Rounds past, I reviewed methods (the dojo) and traced messages (the +tracer). This round I reviewed the thing this framework actually asks +users to design: the **graph**. A plan's dependency structure is a +design artifact exactly like a class diagram, it exhibits the same +smells, and — this is the part people miss — it can be reviewed +*before execution*, when a restructuring costs an edit instead of a +re-run of forty LLM calls. + +Three smells, drawn from their object-design cousins: + +- **God task** — `join` gathers five dependencies, the graph's version + of a class with five collaborators in its constructor. Does it join, + or does it *do everything*? Staged joins give each join one reason + to wait, as extraction gives each class one reason to change. +- **Deep chain** — `publish` sits five levels down. Every level is + latency and a failure domain, the graph's train-wreck method chain. +- **Orphan** — `lonely` touches nothing and is touched by nothing. + Either it belongs to another plan or its justifying connection was + forgotten. Dead code, graph edition. + +And one prescription, as always. A review that emits three findings +and no ordering is a wall; the critic says *start with the god task* — +because restructuring it may dissolve the chain, and cheap moves that +might obsolete expensive ones go first. + +## The feature request embedded in this example + +The critic reads the graph with +`orchestrator.instance_variable_get(:@dependencies)` — a crowbar. I +used it deliberately and left the comment in, because that line *is* +the finding: the orchestrator knows its own topology and offers no +read-only view of it. Aaron's Gantt wanted it (he rebuilt the graph +from hooks), the tracer wanted it, now the critic. Three tools, three +reconstructions of state the object already holds. `Orchestrator#graph` +returning frozen `{task_id => dependency_ids}` plus the task list is +one accessor and unlocks a whole genre of tooling. Objects that keep +useful knowledge private force their collaborators into archaeology. + +## Verdict + +Graphs are designs; designs deserve review; review before execution is +the cheapest review there is. The critic took an evening, found three +seeded smells and their real prescription — and its own best finding +was the accessor the framework should grow next. diff --git a/examples/graph_critic.rb b/examples/graph_critic.rb new file mode 100644 index 0000000..c48b1e2 --- /dev/null +++ b/examples/graph_critic.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# The Graph Critic: reviews a plan's dependency structure BEFORE it +# runs, the way you'd review a class diagram. God tasks, deep chains, +# and orphans are design smells in a graph exactly as they are in +# objects - and they're cheaper to fix before execution than after. +# +# bundle exec ruby examples/graph_critic.rb +# +# Runs offline; no task is executed. The review IS the program. + +require_relative "../lib/agentic" + +def task_named(name) + Agentic::Task.new( + description: name, + agent_spec: {"name" => name, "instructions" => "work"} + ) +end + +# A plan with three deliberate smells +orchestrator = Agentic::PlanOrchestrator.new +tasks = {} +%w[ingest_a ingest_b ingest_c ingest_d ingest_e clean join report publish lonely].each do |name| + tasks[name] = task_named(name) +end + +orchestrator.add_task(tasks["ingest_a"]) +orchestrator.add_task(tasks["ingest_b"]) +orchestrator.add_task(tasks["ingest_c"]) +orchestrator.add_task(tasks["ingest_d"]) +orchestrator.add_task(tasks["ingest_e"]) +# the god task: everything funnels through join +orchestrator.add_task(tasks["join"], %w[ingest_a ingest_b ingest_c ingest_d ingest_e].map { |n| tasks[n] }) +# a chain hanging off it +orchestrator.add_task(tasks["clean"], [tasks["join"]]) +orchestrator.add_task(tasks["report"], [tasks["clean"]]) +orchestrator.add_task(tasks["publish"], [tasks["report"]]) +# and a task nobody references +orchestrator.add_task(tasks["lonely"]) + +# --- the critique ---------------------------------------------------------- +# The graph is private, so the critic borrows a crowbar. A read-only +# graph view on the orchestrator is this example's feature request. +dependencies = orchestrator.instance_variable_get(:@dependencies) +names = orchestrator.tasks.transform_values(&:description) + +dependents = Hash.new { |h, k| h[k] = [] } +dependencies.each { |task_id, deps| deps.each { |dep| dependents[dep] << task_id } } + +depth_of = lambda do |task_id, memo = {}| + memo[task_id] ||= 1 + (dependencies[task_id].map { |dep| depth_of.call(dep, memo) }.max || 0) +end + +findings = [] + +dependencies.each do |task_id, deps| + if deps.size >= 4 + findings << {smell: "god task", task: names[task_id], + note: "gathers #{deps.size} dependencies - does it join, or does it do everything? " \ + "consider staged joins so each has one reason to wait"} + end +end + +deepest = dependencies.keys.max_by { |task_id| depth_of.call(task_id) } +if depth_of.call(deepest) >= 4 + findings << {smell: "deep chain", task: names[deepest], + note: "sits #{depth_of.call(deepest)} levels down - every level is latency and a failure " \ + "domain; could any middle link merge with a neighbor?"} +end + +dependencies.each do |task_id, deps| + if deps.empty? && dependents[task_id].empty? && dependencies.size > 1 + findings << {smell: "orphan", task: names[task_id], + note: "no dependencies, no dependents - is it in the wrong plan, or is the " \ + "connection that justifies it missing?"} + end +end + +puts "GRAPH CRITIC: #{dependencies.size} tasks reviewed before execution" +puts +findings.each do |finding| + puts " [#{finding[:smell]}] #{finding[:task]}" + puts " #{finding[:note]}" + puts +end + +puts "prescription: fix ONE - start with the god task. five ingests" +puts "joining at once usually means 'join' hides a pipeline: stage the" +puts "joins (a+b, c+d+e, then both) and each join gets one reason to" +puts "change. rerun the critic; the chain may resolve itself." From b4afde417cfd361a54f4979adb6b9e3e6b53f772 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:02:52 +0000 Subject: [PATCH 45/46] docs: README verifier example (ankane round 4); fix broken README snippet Parses every ruby fence with Prism and resolves every Agentic constant named against the loaded gem. First run caught the capability-composition snippet's literal '{ data: { ... } }' - the exact snippet round 1 flagged as docs-ahead-of-code - now fixed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- README.md | 2 +- docs/perspectives/round-4/10-ankane.md | 50 ++++++++++++++ examples/readme_verifier.rb | 94 ++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docs/perspectives/round-4/10-ankane.md create mode 100644 examples/readme_verifier.rb diff --git a/README.md b/README.md index 19930e0..b7aaa2d 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,7 @@ registry.compose( # Use the composed capability agent.add_capability("comprehensive_report") -result = agent.execute_capability("comprehensive_report", { data: { ... } }) +result = agent.execute_capability("comprehensive_report", { data: { sales: [120, 90, 143] } }) ``` ## Learning System diff --git a/docs/perspectives/round-4/10-ankane.md b/docs/perspectives/round-4/10-ankane.md new file mode 100644 index 0000000..a01ad29 --- /dev/null +++ b/docs/perspectives/round-4/10-ankane.md @@ -0,0 +1,50 @@ +# Round 4 field notes — Andrew Kane makes the README testify + +*Built: `examples/readme_verifier.rb` — every ruby fence in the README +parsed with Prism and every `Agentic::` constant it names checked +against the loaded gem. Exit 1 on broken promises.* + +## What I built and why + +Back in round 2 I wrote: "my rule: every README snippet is a CI-run +test. The fake web_search survived because nothing ran the promises +the README made." Four rounds later I built the enforcement. The +verifier extracts all 21 ruby fences (376 lines of promised code), +fans them out, syntax-checks each with Prism, and resolves every +`Agentic::`-prefixed constant a snippet mentions against the actual +loaded gem — because a snippet that parses but names +`Agentic::MetaLearningSystem` is still a lie, just a better-dressed +one. + +**First run: caught one.** README line 514, the capability-composition +example, contained `{ data: { ... } }` — a literal ellipsis inside a +hash, unparseable since the day it was written. And here's the kicker: +the very first persona review in round 1 (DHH) called out *this exact +snippet* as "the README being ahead of the code." It took us four +rounds and a tool to convert that observation from an opinion into an +exit code. Opinions decay; exit codes don't. + +## Design notes + +- **Constant resolution beats execution.** I deliberately don't *run* + snippets — half of them need API keys, several would write files. + Parse + resolve gets you 90% of the lie-detection with 0% of the + side effects; it's the right default tier (same reasoning as the + DuckDuckGo backend: free and honest first, paid and thorough as an + upgrade). Executing the safe subset in a sandbox is the `--strict` + flag this grows next. +- The survey/atlas shape again (Xavier's right that it needs a name): + per-snippet checks in parallel, one verdict fanning in with + `t.output_of(check)`. Fourth build in two rounds with this skeleton; + it's the framework's `map/reduce`. +- One `rescue NameError` per constant lookup, and note it must be + `Object.const_get`, not `eval` — verifiers that eval their input + become the vulnerability they were hired to prevent. + +## Verdict + +Wire this into CI next to Jeremy's fuzzer and the docs can never +silently rot again: the fuzzer keeps the contracts honest, this keeps +the promises honest. It found a four-round-old lie on its first run — +tools that pay for themselves before the commit lands are the only +kind worth writing. diff --git a/examples/readme_verifier.rb b/examples/readme_verifier.rb new file mode 100644 index 0000000..5bdae1e --- /dev/null +++ b/examples/readme_verifier.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# The README Verifier: every ruby code fence in the README is a promise. +# This extracts them all, syntax-checks each with Prism, and verifies +# that every Agentic constant a snippet mentions actually exists in the +# loaded gem. Docs rot silently; this makes the rot loud. +# +# bundle exec ruby examples/readme_verifier.rb [markdown_file] +# +# Runs offline. Exit 1 if the README promises anything the gem can't keep. + +require_relative "../lib/agentic" +require "prism" + +README = File.expand_path(ARGV.first || "#{__dir__}/../README.md") + +# Pull ruby code fences with their line numbers +snippets = [] +current = nil +File.readlines(README, encoding: "UTF-8").each_with_index do |line, index| + if current + if line.start_with?("```") + snippets << current + current = nil + else + current[:code] << line + end + elsif line.start_with?("```ruby") + current = {line: index + 2, code: +""} + end +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) + +checks = snippets.map do |snippet| + task = Agentic::Task.new( + description: "snippet at line #{snippet[:line]}", + agent_spec: {"name" => "Verifier", "instructions" => "verify the snippet"}, + payload: snippet + ) + orchestrator.add_task(task, agent: ->(t) { + code = t.payload[:code] + parsed = Prism.parse(code) + + missing = code.scan(/Agentic(?:::[A-Z]\w*)+/).uniq.reject { |const| + begin + Object.const_get(const) + true + rescue NameError + false + end + } + + { + line: t.payload[:line], + lines: code.lines.size, + syntax_errors: parsed.errors.map { |e| "#{e.message} (snippet line #{e.location.start_line})" }, + missing_constants: missing + } + }) + task +end + +verdict = Agentic::Task.new( + description: "the verdict", + agent_spec: {"name" => "Editor", "instructions" => "sum it up"} +) +orchestrator.add_task(verdict, checks, agent: ->(t) { + reports = checks.map { |c| t.output_of(c) } + { + total: reports.size, + total_lines: reports.sum { |r| r[:lines] }, + broken: reports.select { |r| r[:syntax_errors].any? || r[:missing_constants].any? } + } +}) + +result = orchestrator.execute_plan +report = result.results[verdict.id].output + +puts "README VERIFIER: #{File.basename(README)}" +puts " #{report[:total]} ruby snippets, #{report[:total_lines]} lines of promised code" +puts + +if report[:broken].empty? + puts " every snippet parses and every Agentic constant it names exists." + puts " the README keeps its promises." +else + report[:broken].each do |broken| + puts " BROKEN: snippet at README line #{broken[:line]}" + broken[:syntax_errors].each { |e| puts " syntax: #{e}" } + broken[:missing_constants].each { |c| puts " missing constant: #{c}" } + end + exit 1 +end From 9432bbdf7f9fe11010577f416c084f8614ecc945 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:04:10 +0000 Subject: [PATCH 46/46] docs: round-4 index and findings Adds the round-4 table to the perspectives README: ten new experiments on the round-4 release, two more defects found by examples (canceled-status lie, four-round-old broken README snippet), and the next prioritized asks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/README.md | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md index 8b53479..486f826 100644 --- a/docs/perspectives/README.md +++ b/docs/perspectives/README.md @@ -65,6 +65,46 @@ by one of these builds). The personas then built ten *new* things: | 9 | Sandi Metz | Collaboration tracer — plans as sequence diagrams | `examples/collaboration_tracer.rb` | [round-3/09-sandimetz.md](round-3/09-sandimetz.md) | | 10 | Andrew Kane | Changelog scout — release notes from real git history | `examples/changelog_scout.rb` | [round-3/10-ankane.md](round-3/10-ankane.md) | +## Round 4 — the asks become grammar + +The round-3 asks shipped as a release (named dependencies via `needs:`, +`Task#previous_output`, the `task_slot_acquired` hook, retry policies +consulting `failure.retryable?`, and contract value predicates — +`enum:`, `min:`/`max:`, `non_empty:`), and ten more experiments followed: + +| # | Persona | Built on the round-4 release | Run it | Field notes | +|---|---------|------------------------------|--------|-------------| +| 1 | Matz | Exquisite corpse — creature parts assembled by name | `examples/exquisite_corpse.rb` | [round-4/01-matz.md](round-4/01-matz.md) | +| 2 | DHH | Setup doctor — the onboarding wiki, deleted | `examples/setup_doctor.rb` | [round-4/02-dhh.md](round-4/02-dhh.md) | +| 3 | Aaron Patterson | Knee finder — measured concurrency recommendations | `examples/knee_finder.rb` | [round-4/03-tenderlove.md](round-4/03-tenderlove.md) | +| 4 | Xavier Noria | Coupling cartographer — the constant-reference force map | `examples/coupling_cartographer.rb` | [round-4/04-fxn.md](round-4/04-fxn.md) | +| 5 | Samuel Williams | Shared rate limit — one credential ceiling across two plans | `examples/shared_rate_limit.rb` | [round-4/05-ioquatix.md](round-4/05-ioquatix.md) | +| 6 | Jeremy Evans | Invariant sentinel — laws checked after every task (found the `:canceled` status bug) | `examples/invariant_sentinel.rb` | [round-4/06-jeremyevans.md](round-4/06-jeremyevans.md) | +| 7 | Piotr Solnica | Contract state machine — enum guards instead of transition tables | `examples/state_machine.rb` | [round-4/07-solnic.md](round-4/07-solnic.md) | +| 8 | Mike Perham | Error taxonomy drill — errors testify about their own retryability | `examples/error_taxonomy_drill.rb` | [round-4/08-mperham.md](round-4/08-mperham.md) | +| 9 | Sandi Metz | Graph critic — design review for dependency graphs, pre-execution | `examples/graph_critic.rb` | [round-4/09-sandimetz.md](round-4/09-sandimetz.md) | +| 10 | Andrew Kane | README verifier — every snippet parsed, every constant resolved (found a 4-round-old broken snippet) | `examples/readme_verifier.rb` | [round-4/10-ankane.md](round-4/10-ankane.md) | + +### What round 4 surfaced + +1. **Two more real defects found by examples**: canceled plans reported + `:completed` (`overall_status` never consulted the canceled state — + fixed, regression-tested), and the README's composition snippet had + been syntactically invalid since round 1's review first side-eyed it + (fixed; the verifier now guards it). +2. **Every round-3 ask got exercised the round it shipped** — named + deps (corpse, doctor), slot hook (knee finder), `retryable?` + (taxonomy drill), predicates (state machine). Tight feedback loops + keep features honest. +3. **The survey/atlas shape is the framework's signature** — parallel + facts, one fan-in verdict — now in six examples. It deserves a + documented name. +4. **Next asks**: a read-only `Orchestrator#graph` accessor (three + tools have crowbarred `@dependencies`), violation payloads carrying + the predicate's expectation (legal enum values), a credential-scoped + `RateLimit` class (`LlmClient` accepting `limiter:`), jitter-on by + default, and cross-field contract rules. + ### What round 3 surfaced 1. **The adapter tax is gone.** Zero provider structs, zero