Duck Agents
Duck Agents: the agent: seam asks one question - "can you be called with a task?" - and five differently-shaped objects all answer yes: a lambda, an instance, a Method, a curried proc, and a decorator wrapping any of the others. Nobody was asked what class they are. Ask for what you need, not for who someone is.
Plans & Graphs
Round 9
Sandi Metz
exit 0
bundle exec ruby examples/duck_agents.rb
a real captured run
DUCK AGENTS (one seam, five shapes)
fetch lambda -> {:records=>12, :source=>"fetch"}
dedupe instance with #call -> {:unique=>9, :dropped=>3, :pass=>1}
stats Method object -> {:mean=>4.2, :max=>9}
render curried proc -> {:rendered=>true, :format=>"html"}
audit decorator around a lambda -> {:audited=>true, :timed_ms=>0.0}
the seam asked one question: respond to call (or execute) with a
task. it never asked anyone's class, so anything shaped right
walks in: closures for the quick cases, instances when logic
deserves a home, Method objects when it already has one, curry
for pre-applied config, and decorators that stack because they
honor the same contract they consume. depend on messages and
every object ever written - and not yet written - is a plugin.
source
# frozen_string_literal: true # Duck Agents: the agent: seam asks one question - "can you be called # with a task?" - and five differently-shaped objects all answer yes: # a lambda, an instance, a Method, a curried proc, and a decorator # wrapping any of the others. Nobody was asked what class they are. # Ask for what you need, not for who someone is. # # bundle exec ruby examples/duck_agents.rb # # Runs offline; one plan, five ducks, one timing decorator. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # Duck 1: a lambda - the shape you reach for first fetch = ->(task) { {records: 12, source: task.description} } # Duck 2: a plain object with #call - state and a real home for logic class Deduper def initialize @seen = 0 end def call(_task) @seen += 1 {unique: 9, dropped: 3, pass: @seen} end end # Duck 3: a Method object - module functions join the plan unwrapped module Stats def self.summarize(_task) {mean: 4.2, max: 9} end end # Duck 4: a curried proc - configuration applied ahead of time, # leaving exactly the one-argument shape the seam asks for render = lambda { |format, _task| {rendered: true, format: format} } render_html = render.curry[class="s">"html"] # Duck 5: a decorator - wraps ANY of the above, because it only # relies on the same one-message contract it provides class Timed def initialize(inner) @inner = inner end def call(task) started = Process.clock_gettime(Process:class="y">:CLOCK_MONOTONIC) @inner.call(task).merge( timed_ms: ((Process.clock_gettime(Process:class="y">:CLOCK_MONOTONIC) - started) * 1000).round(2) ) end end def task(name) Agentic:class="y">:Task.new(description: name, agent_spec: {class="s">"name" => name, class="s">"instructions" => class="s">"work"}) end orchestrator = Agentic:class="y">:PlanOrchestrator.new fetch_task = task(class="s">"fetch") dedupe_task = task(class="s">"dedupe") stats_task = task(class="s">"stats") render_task = task(class="s">"render") audit_task = task(class="s">"audit") orchestrator.add_task(fetch_task, agent: fetch) orchestrator.add_task(dedupe_task, [fetch_task], agent: Deduper.new) orchestrator.add_task(stats_task, [dedupe_task], agent: Stats.method(class="y">:summarize)) orchestrator.add_task(render_task, [stats_task], agent: render_html) orchestrator.add_task(audit_task, [render_task], agent: Timed.new(->(_t) { {audited: true} })) result = orchestrator.execute_plan DUCKS = { class="s">"fetch" => class="s">"lambda", class="s">"dedupe" => class="s">"instance with #call", class="s">"stats" => class="s">"Method object", class="s">"render" => class="s">"curried proc", class="s">"audit" => class="s">"decorator around a lambda" }.freeze puts class="s">"DUCK AGENTS (one seam, five shapes)" puts [fetch_task, dedupe_task, stats_task, render_task, audit_task].each do |t| output = result.task_result(t.id).output puts format(class="s">" %-8s %-26s -> %s", t.description, DUCKS[t.description], output) end puts puts class="s">" the seam asked one question: respond to call (or execute) with a" puts class="s">" task. it never asked anyone's class, so anything shaped right" puts class="s">" walks in: closures for the quick cases, instances when logic" puts class="s">" deserves a home, Method objects when it already has one, curry" puts class="s">" for pre-applied config, and decorators that stack because they" puts class="s">" honor the same contract they consume. depend on messages and" puts class="s">" every object ever written - and not yet written - is a plugin."