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/README.md b/README.md index e35077e..b7aaa2d 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,84 @@ 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) }`. + +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) +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: @@ -454,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/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/README.md b/docs/perspectives/README.md new file mode 100644 index 0000000..486f826 --- /dev/null +++ b/docs/perspectives/README.md @@ -0,0 +1,294 @@ +# 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. + +## Round 1 — reviewing and repairing the framework + +| # | 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) | + +## 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) | + +## 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) | + +## 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 + 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: + +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 + +**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. 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/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/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/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/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/docs/perspectives/field-notes/06-jeremyevans.md b/docs/perspectives/field-notes/06-jeremyevans.md new file mode 100644 index 0000000..1c2efd2 --- /dev/null +++ b/docs/perspectives/field-notes/06-jeremyevans.md @@ -0,0 +1,61 @@ +# 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. + +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 + 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 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." 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." 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 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 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 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}" 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" 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" 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")}" 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 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." 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 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/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)" 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!" 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" diff --git a/examples/namespace_cartographer.rb b/examples/namespace_cartographer.rb new file mode 100644 index 0000000..647717e --- /dev/null +++ b/examples/namespace_cartographer.rb @@ -0,0 +1,113 @@ +# 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") + +files = Dir[File.join(LIB, "**", "*.rb")].sort +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) +tasks = files.to_h do |path| + task = Agentic::Task.new( + description: File.basename(path), + agent_spec: {"name" => "Cartographer", "instructions" => "Survey the file"}, + payload: path + ) + orchestrator.add_task(task, agent: ->(t) { + surveyor.execute_capability("survey_file", {path: t.payload})[:defined] + }) + [path, task] +end +result = orchestrator.execute_plan +charts = tasks.transform_values { |task| result.results[task.id].output } + +# 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 diff --git a/examples/performance_detective.rb b/examples/performance_detective.rb new file mode 100644 index 0000000..d5ae85d --- /dev/null +++ b/examples/performance_detective.rb @@ -0,0 +1,92 @@ +# 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 with the path as payload +concurrency = (ARGV.first || 16).to_i +files = Dir[File.join(LIB, "**", "*.rb")].sort + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency) +tasks = files.map do |path| + task = Agentic::Task.new( + description: File.basename(path), + agent_spec: {"name" => "Detective", "instructions" => "Dissect the file"}, + 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 +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 + +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 diff --git a/examples/plan_gantt.rb b/examples/plan_gantt.rb new file mode 100644 index 0000000..7bac663 --- /dev/null +++ b/examples/plan_gantt.rb @@ -0,0 +1,79 @@ +# 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 + }, + 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 + } +} + +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 + 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; '.' = 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)", + serial_floor * 1000, result.execution_time * 1000, serial_floor / result.execution_time) 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 diff --git a/examples/refactoring_dojo.rb b/examples/refactoring_dojo.rb new file mode 100644 index 0000000..11231f3 --- /dev/null +++ b/examples/refactoring_dojo.rb @@ -0,0 +1,126 @@ +# 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 rides its own task and reviews in parallel +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) +seats = critics.to_h do |c| + task = Agentic::Task.new( + description: c.name, + agent_spec: {"name" => c.name, "instructions" => "Review the submission"}, + payload: c + ) + orchestrator.add_task(task, agent: ->(t) { + t.payload.execute_capability(t.payload.name, {source: source})[:findings] + }) + [c.name, task] +end +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)}" +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 diff --git a/examples/renga_circle.rb b/examples/renga_circle.rb new file mode 100644 index 0000000..4dba311 --- /dev/null +++ b/examples/renga_circle.rb @@ -0,0 +1,87 @@ +# 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, and the +# orchestrator pipes each verse into the poet who answers it. +# +# bundle exec ruby examples/renga_circle.rb +# +# Runs offline: each poet's craft is a lambda-backed capability. + +require_relative "../lib/agentic" + +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 + +theme = ARGV.first || "autumn wind" +orchestrator = Agentic::PlanOrchestrator.new + +# 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" + ), + payload: theme + ) + + 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 + +puts " ~ a renga on \"#{theme}\" ~" +puts +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 new file mode 100644 index 0000000..2e20c85 --- /dev/null +++ b/examples/schema_advisor.rb @@ -0,0 +1,139 @@ +# 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) } + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) +SCHEMA.each do |table, definition| + orchestrator.add_task(Agentic::Task.new( + description: table, + agent_spec: {"name" => "DBA", "instructions" => "Review the table"}, + 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 +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})" +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 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 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." 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)" 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." 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)" diff --git a/examples/ticket_screener.rb b/examples/ticket_screener.rb new file mode 100644 index 0000000..d33f9c7 --- /dev/null +++ b/examples/ticket_screener.rb @@ -0,0 +1,116 @@ +# 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 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 = 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 + +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"}, + payload: ticket + ), agent: screen_ticket) +end +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 + +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)" 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) } diff --git a/lib/agentic.rb b/lib/agentic.rb index 992da6b..156577e 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 @@ -32,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 @@ -61,17 +73,54 @@ 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 { |task_def| orchestrator.add_task(task_def.to_task) } + + 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 @@ -82,7 +131,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 +148,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.rb b/lib/agentic/agent.rb index 557dfa7..aad17e8 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 @@ -30,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 @@ -61,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] = { @@ -99,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] @@ -123,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 @@ -148,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/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/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/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..9e30247 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 @@ -106,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/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..972aa25 --- /dev/null +++ b/lib/agentic/capability_validator.rb @@ -0,0 +1,89 @@ +# 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) + + # 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, **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 + end + + def symbolize_keys(values) + values.to_h { |key, value| [key.to_sym, value] } + end + end +end diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index 0b22967..65da564 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 @@ -208,7 +207,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] || "" @@ -812,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/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 70db8a7..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 @@ -594,6 +596,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/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/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/execution_journal.rb b/lib/agentic/execution_journal.rb new file mode 100644 index 0000000..103044a --- /dev/null +++ b/lib/agentic/execution_journal.rb @@ -0,0 +1,152 @@ +# 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, :descriptions, + keyword_init: true + ) do + # @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?(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 + + # @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, 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, 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) + 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: [], + descriptions: {} + ) + + 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 + if entry[:task_id] && entry[:description] + state.descriptions[entry[:task_id]] = entry[:description] + end + + 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/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/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/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..e069791 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 @@ -20,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/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/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 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/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/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..090b4d7 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 @@ -38,6 +35,8 @@ 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 = { @@ -46,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 @@ -58,21 +60,54 @@ 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. 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] 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 + # @param needs [Hash{Symbol=>Task,String}, nil] Named dependencies # @return [void] - def add_task(task, dependencies = []) + def add_task(task, dependencies = [], agent: nil, needs: nil) task_id = task.id @tasks[task_id] = task - @dependencies[task_id] = Array(dependencies) + 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 # Executes the plan, respecting task dependencies and concurrency limits - # @param agent_provider [Object] An object that provides agents for task execution + # + # 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, 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) - @reactor = Async do |reactor| + 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) @@ -166,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 @@ -207,12 +248,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 @@ -238,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 @@ -260,88 +304,126 @@ 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 + + # 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, 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( + # 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. + scheduled_at = Time.now + async_task = barrier.async do + semaphore.acquire do + @lifecycle_hooks[:task_slot_acquired].call( task_id: task_id, - task: task + task: task, + waited: Time.now - scheduled_at ) + execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) + end + end - agent_build_start = Time.now - agent = agent_provider.get_agent_for_task(task) - agent_build_duration = Time.now - agent_build_start + # Store the async task for potential cancellation + @async_tasks[task_id] = async_task + end - # Call after_agent_build hook - @lifecycle_hooks[:after_agent_build].call( - task_id: task_id, - task: task, - agent: agent, - build_duration: agent_build_duration - ) + # 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 - 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" - }) + # Call before_agent_build hook + @lifecycle_hooks[:before_agent_build].call( + task_id: task_id, + task: task + ) - record_task_failure(task_id, failure) + agent_build_start = Time.now + agent = resolve_agent(task, agent_provider) + agent_build_duration = Time.now - agent_build_start - # 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 - ) + # Call after_agent_build hook + @lifecycle_hooks[:after_agent_build].call( + task_id: task_id, + task: task, + agent: agent, + build_duration: agent_build_duration + ) - Agentic.logger.error("Unexpected error in task #{task_id}: #{e.message}") - end + 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" + }) - # Store the async task for potential cancellation - @async_tasks[task_id] = async_task + 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 @@ -440,6 +522,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 dcade6a..595ba10 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 @@ -24,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 @@ -46,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 @@ -65,6 +69,40 @@ 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 + + # 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 @@ -91,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_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/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_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/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 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/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 diff --git a/spec/agentic/capability_validator_spec.rb b/spec/agentic/capability_validator_spec.rb new file mode 100644 index 0000000..25cc25e --- /dev/null +++ b/spec/agentic/capability_validator_spec.rb @@ -0,0 +1,172 @@ +# 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 + +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/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/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/agentic/execution_journal_spec.rb b/spec/agentic/execution_journal_spec.rb new file mode 100644 index 0000000..9f564c0 --- /dev/null +++ b/spec/agentic/execution_journal_spec.rb @@ -0,0 +1,134 @@ +# 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 + + 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/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 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/agentic/plan_orchestrator_direct_agents_spec.rb b/spec/agentic/plan_orchestrator_direct_agents_spec.rb new file mode 100644 index 0000000..9fc3d10 --- /dev/null +++ b/spec/agentic/plan_orchestrator_direct_agents_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "spec_helper" +require "timeout" + +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 "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") + 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 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 diff --git a/spec/agentic/round4_features_spec.rb b/spec/agentic/round4_features_spec.rb new file mode 100644 index 0000000..3fecb82 --- /dev/null +++ b/spec/agentic/round4_features_spec.rb @@ -0,0 +1,186 @@ +# 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 "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( + 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 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 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 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 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