The Plan DSL
The Plan DSL: Sinatra's whole argument was that an API is a user interface, and a user interface should read like what it means. The orchestrator's API is honest but administrative - ids, task objects, add_task bookkeeping. Thirty lines of DSL later, a plan reads like a plan. No engine changes: sugar OVER the API, never reaching into it.
Developer Experience
Round 12
Konstantin Haase
exit 0
bundle exec ruby examples/plan_dsl.rb
a real captured run
THE PLAN DSL (thirty lines of sugar over the real API) net revenue: $120 and it's all real underneath: 4 tasks, labeled edges (orders, refunds), same graph every round-5-to-11 tool consumes. what the sugar buys: names instead of ids (symbols resolve to tasks at definition time, so a typo'd :fetch_order fails at DEFINE, not at run); the block IS the agent (the work sits inside the step that owns it); and after:/needs: read as English. what the sugar refuses: reaching into the engine. every line delegates to public API - add_task, execute_plan, graph - so the DSL can never drift ahead of what the engine supports, and anything the DSL can't express, you drop down one layer without rewriting. Sinatra's rule: the frontend should be a pleasure and the escape hatch should be a door, not a wall.
source
# frozen_string_literal: true # The Plan DSL: Sinatra's whole argument was that an API is a user # interface, and a user interface should read like what it means. # The orchestrator's API is honest but administrative - ids, task # objects, add_task bookkeeping. Thirty lines of DSL later, a plan # reads like a plan. No engine changes: sugar OVER the API, never # reaching into it. # # bundle exec ruby examples/plan_dsl.rb # # Runs offline; the DSL builds a real orchestrator underneath. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # The whole DSL. Symbols in, wiring out; the block IS the agent. module Plan def self.define(&block) builder = Builder.new builder.instance_eval(&block) builder end class Builder attr_reader class="y">:orchestrator def initialize @orchestrator = Agentic:class="y">:PlanOrchestrator.new @tasks = {} end def step(name, after: [], needs: nil, &work) task = Agentic:class="y">:Task.new(description: name.to_s, agent_spec: {class="s">"name" => name.to_s, class="s">"instructions" => name.to_s}) @tasks[name] = task deps = Array(after).map { |n| @tasks.fetch(n) } named = needs&.transform_values { |n| @tasks.fetch(n) } @orchestrator.add_task(task, deps, needs: named, agent: ->(t) { work&.call(t) }) self end def run @orchestrator.execute_plan end def output_of(name, result) result.task_result(@tasks.fetch(name).id).output end end end # --- a plan that reads like a plan ---------------------------------------------- plan = Plan.define do step class="y">:fetch_orders do [{id: 1, total: 120}, {id: 2, total: 80}] end step class="y">:fetch_refunds do [{order_id: 2, amount: 80}] end step class="y">:ledger, needs: {orders: class="y">:fetch_orders, refunds: class="y">:fetch_refunds} do |t| t.needs[class="y">:orders].sum { |o| o[class="y">:total] } - t.needs[class="y">:refunds].sum { |r| r[class="y">:amount] } end step class="y">:report, after: class="y">:ledger do |t| class="s">"net revenue: $#{t.previous_output}" end end result = plan.run puts class="s">"THE PLAN DSL (thirty lines of sugar over the real API)" puts puts class="s">" #{plan.output_of(class="y">:report, result)}" puts graph = plan.orchestrator.graph puts class="s">" and it's all real underneath: #{graph[class="y">:tasks].size} tasks, labeled edges" puts class="s">" (#{graph[class="y">:edges].filter_map { |e| e[class="y">:label] }.join(", class="s">")}), same graph every round-5-to-11 tool consumes." puts puts class="s">" what the sugar buys: names instead of ids (symbols resolve to" puts class="s">" tasks at definition time, so a typo'd class="y">:fetch_order fails at" puts class="s">" DEFINE, not at run); the block IS the agent (the work sits" puts class="s">" inside the step that owns it); and after:/needs: read as" puts class="s">" English. what the sugar refuses: reaching into the engine." puts class="s">" every line delegates to public API - add_task, execute_plan," puts class="s">" graph - so the DSL can never drift ahead of what the engine" puts class="s">" supports, and anything the DSL can't express, you drop down" puts class="s">" one layer without rewriting. Sinatra's rule: the frontend" puts class="s">" should be a pleasure and the escape hatch should be a door," puts class="s">" not a wall."