mars_rb 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +38 -0
  3. data/README.md +183 -12
  4. data/examples/complex_llm_workflow/diagram.md +32 -0
  5. data/examples/complex_llm_workflow/generator.rb +112 -0
  6. data/examples/complex_workflow/diagram.md +47 -0
  7. data/examples/complex_workflow/generator.rb +108 -0
  8. data/examples/parallel_workflow/diagram.md +21 -0
  9. data/examples/parallel_workflow/generator.rb +33 -0
  10. data/examples/simple_workflow/diagram.md +26 -0
  11. data/examples/simple_workflow/generator.rb +48 -0
  12. data/lib/mars/agent_step.rb +15 -0
  13. data/lib/mars/aggregator.rb +24 -0
  14. data/lib/mars/execution_context.rb +35 -0
  15. data/lib/mars/formatter.rb +13 -0
  16. data/lib/mars/gate.rb +52 -0
  17. data/lib/mars/hooks.rb +35 -0
  18. data/lib/mars/rendering/graph/agent_step.rb +18 -0
  19. data/lib/mars/rendering/graph/aggregator.rb +17 -0
  20. data/lib/mars/rendering/graph/base.rb +32 -0
  21. data/lib/mars/rendering/graph/builder.rb +43 -0
  22. data/lib/mars/rendering/graph/gate.rb +22 -0
  23. data/lib/mars/rendering/graph/node.rb +22 -0
  24. data/lib/mars/rendering/graph/parallel_workflow.rb +34 -0
  25. data/lib/mars/rendering/graph/runnable.rb +18 -0
  26. data/lib/mars/rendering/graph/sequential_workflow.rb +39 -0
  27. data/lib/mars/rendering/graph/subgraph.rb +17 -0
  28. data/lib/mars/rendering/graph.rb +16 -0
  29. data/lib/mars/rendering/html.rb +37 -0
  30. data/lib/mars/rendering/mermaid.rb +71 -0
  31. data/lib/mars/runnable.rb +26 -2
  32. data/lib/mars/version.rb +2 -2
  33. data/lib/mars/workflows/aggregate_error.rb +14 -0
  34. data/lib/mars/workflows/parallel.rb +61 -0
  35. data/lib/mars/workflows/sequential.rb +26 -8
  36. data/lib/mars.rb +12 -3
  37. data/lib/mars_rb.rb +3 -0
  38. data/sig/mars.rbs +1 -1
  39. metadata +81 -8
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../../lib/mars"
5
+
6
+ # Define the LLMs
7
+ class Agent1 < MARS::AgentStep
8
+ end
9
+
10
+ class Agent2 < MARS::AgentStep
11
+ end
12
+
13
+ class Agent3 < MARS::AgentStep
14
+ end
15
+
16
+ class Agent4 < MARS::AgentStep
17
+ end
18
+
19
+ # Create the LLMs
20
+ llm1 = Agent1.new
21
+ llm2 = Agent2.new
22
+ llm3 = Agent3.new
23
+ llm4 = Agent4.new
24
+
25
+ # Create the failure workflow (LLM 3)
26
+ failure_workflow = MARS::Workflows::Sequential.new(
27
+ "Failure workflow",
28
+ steps: [llm4]
29
+ )
30
+
31
+ # Create the gate that decides between exit or continue
32
+ gate = MARS::Gate.new(
33
+ check: ->(input) { input[:result] },
34
+ fallbacks: {
35
+ failure: failure_workflow
36
+ }
37
+ )
38
+
39
+ # Create the main workflow: LLM 1 -> Gate
40
+ main_workflow = MARS::Workflows::Sequential.new(
41
+ "Main Pipeline",
42
+ steps: [llm1, gate, llm2, llm3]
43
+ )
44
+
45
+ # Generate and save the diagram
46
+ diagram = MARS::Rendering::Mermaid.new(main_workflow).render
47
+ File.write("examples/simple_workflow/diagram.md", diagram)
48
+ puts "Simple workflow diagram saved to: examples/simple_workflow/diagram.md"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ class AgentStep < Runnable
5
+ class << self
6
+ def agent(klass = nil)
7
+ klass ? @agent_class = klass : @agent_class
8
+ end
9
+ end
10
+
11
+ def run(context)
12
+ self.class.agent.new.ask(context.current_input).content
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ class Aggregator < Runnable
5
+ attr_reader :operation
6
+
7
+ def initialize(name = "Aggregator", operation: nil, **kwargs)
8
+ super(name: name, **kwargs)
9
+
10
+ @operation = operation || ->(inputs) { inputs }
11
+ end
12
+
13
+ def run(context)
14
+ context = ensure_context(context)
15
+ operation.call(context.current_input)
16
+ end
17
+
18
+ private
19
+
20
+ def ensure_context(input)
21
+ input.is_a?(ExecutionContext) ? input : ExecutionContext.new(input: input)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ class ExecutionContext
5
+ attr_reader :outputs, :global_state
6
+ attr_accessor :current_input
7
+
8
+ def initialize(input: nil, global_state: {})
9
+ @current_input = input
10
+ @outputs = {}
11
+ @global_state = global_state
12
+ end
13
+
14
+ def [](step_name)
15
+ outputs[step_name.to_sym]
16
+ end
17
+
18
+ def record(step_name, output)
19
+ @outputs[step_name.to_sym] = output
20
+ @current_input = output
21
+ end
22
+
23
+ def fork(input: current_input, state: {})
24
+ self.class.new(input: input, global_state: global_state.merge(state))
25
+ end
26
+
27
+ def merge(child_contexts)
28
+ child_contexts.each do |child|
29
+ @outputs.merge!(child.outputs)
30
+ end
31
+
32
+ self
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ class Formatter
5
+ def format_input(context)
6
+ context.current_input
7
+ end
8
+
9
+ def format_output(output)
10
+ output
11
+ end
12
+ end
13
+ end
data/lib/mars/gate.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ class Gate < Runnable
5
+ class << self
6
+ def check(&block)
7
+ @check_block = block
8
+ end
9
+
10
+ attr_reader :check_block
11
+
12
+ def fallback(key, runnable)
13
+ fallbacks_map[key] = runnable
14
+ end
15
+
16
+ def fallbacks_map
17
+ @fallbacks_map ||= {}
18
+ end
19
+ end
20
+
21
+ def initialize(name = "Gate", check: nil, fallbacks: nil, **kwargs)
22
+ super(name: name, **kwargs)
23
+
24
+ @check = check || self.class.check_block
25
+ @fallbacks = fallbacks || self.class.fallbacks_map
26
+ end
27
+
28
+ def run(context)
29
+ context = ensure_context(context)
30
+ result = check.call(context)
31
+
32
+ return context if result.nil? || result == :default
33
+
34
+ branch = fallbacks[result]
35
+ raise ArgumentError, "No fallback registered for #{result.inspect}" unless branch
36
+
37
+ resolve_branch(branch).run(context)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :check, :fallbacks
43
+
44
+ def resolve_branch(branch)
45
+ branch.is_a?(Class) ? branch.new : branch
46
+ end
47
+
48
+ def ensure_context(input)
49
+ input.is_a?(ExecutionContext) ? input : ExecutionContext.new(input: input)
50
+ end
51
+ end
52
+ end
data/lib/mars/hooks.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Hooks
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def before_run(&block)
11
+ before_run_hooks << block
12
+ end
13
+
14
+ def after_run(&block)
15
+ after_run_hooks << block
16
+ end
17
+
18
+ def before_run_hooks
19
+ @before_run_hooks ||= []
20
+ end
21
+
22
+ def after_run_hooks
23
+ @after_run_hooks ||= []
24
+ end
25
+ end
26
+
27
+ def run_before_hooks(context)
28
+ self.class.before_run_hooks.each { |hook| hook.call(context, self) }
29
+ end
30
+
31
+ def run_after_hooks(context, result)
32
+ self.class.after_run_hooks.each { |hook| hook.call(context, result, self) }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module AgentStep
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_node(node_id, name, Node::STEP)
11
+ builder.add_edge(parent_id, node_id, value)
12
+
13
+ [node_id]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module Aggregator
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_edge(parent_id, node_id, value)
11
+
12
+ [node_id]
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module Base
7
+ def build_graph(builder = MARS::Rendering::Graph::Builder.new)
8
+ builder.add_node("in", "In", Node::INPUT)
9
+ builder.add_node("out", "Out", Node::OUTPUT)
10
+
11
+ sink_nodes = to_graph(builder, parent_id: "in")
12
+
13
+ sink_nodes.each do |sink_node|
14
+ builder.add_edge(sink_node, "out")
15
+ end
16
+
17
+ [builder.adjacency, builder.nodes, builder.subgraphs]
18
+ end
19
+
20
+ def node_id
21
+ @node_id ||= sanitize(name)
22
+ end
23
+
24
+ private
25
+
26
+ def sanitize(name)
27
+ name.to_s.gsub(/[^a-zA-Z0-9]/, "_").downcase
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ class Builder
7
+ attr_reader :adjacency, :nodes, :subgraphs
8
+
9
+ def initialize
10
+ @adjacency = Hash.new { |h, k| h[k] = [] }
11
+ @nodes = {}
12
+ @subgraphs = {}
13
+ end
14
+
15
+ def add_edge(from, to, value = nil)
16
+ return unless from && to
17
+
18
+ # can we avoid visiting the node twice instead?
19
+ adjacency[from] << [to, value] unless adjacency[from].include?([to, value])
20
+ adjacency[to] = [] unless adjacency[to]
21
+ end
22
+
23
+ def add_node(id, value, type)
24
+ return if nodes.key?(id)
25
+
26
+ nodes[id] = Node.new(id, value, type)
27
+ end
28
+
29
+ def add_subgraph(id, name)
30
+ return if subgraphs.key?(id)
31
+
32
+ subgraphs[id] = Subgraph.new(id, name, [])
33
+ end
34
+
35
+ def add_node_to_subgraph(id, node_id)
36
+ return if subgraphs[id]&.nodes&.include?(node_id)
37
+
38
+ subgraphs[id].nodes << node_id
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module Gate
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_node(node_id, name, Node::GATE)
11
+ builder.add_edge(parent_id, node_id, value)
12
+
13
+ sink_nodes = fallbacks.map do |fallback_key, branch|
14
+ branch.to_graph(builder, parent_id: node_id, value: fallback_key)
15
+ end
16
+
17
+ sink_nodes.flatten
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ class Node
7
+ STEP = :step
8
+ OUTPUT = :output
9
+ INPUT = :input
10
+ GATE = :gate
11
+
12
+ attr_reader :id, :name, :type
13
+
14
+ def initialize(id, name, type)
15
+ @id = id
16
+ @name = name
17
+ @type = type
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module ParallelWorkflow
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_subgraph(node_id, name) if steps.any?
11
+ builder.add_node(aggregator.node_id, aggregator.name, Node::STEP)
12
+
13
+ build_steps_graph(builder, parent_id, value)
14
+
15
+ [aggregator.node_id]
16
+ end
17
+
18
+ private
19
+
20
+ def build_steps_graph(builder, parent_id, value)
21
+ steps.each do |step|
22
+ sink_nodes = step.to_graph(builder, parent_id: parent_id, value: value)
23
+
24
+ builder.add_node_to_subgraph(node_id, step.node_id)
25
+
26
+ sink_nodes.each do |sink_node|
27
+ aggregator.to_graph(builder, parent_id: sink_node)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module Runnable
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_node(node_id, name, Node::STEP)
11
+ builder.add_edge(parent_id, node_id, value)
12
+
13
+ [node_id]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ module SequentialWorkflow
7
+ include Base
8
+
9
+ def to_graph(builder, parent_id: nil, value: nil)
10
+ builder.add_subgraph(node_id, name) if steps.any?
11
+
12
+ parent_id, value, sink_nodes = build_steps_graph(builder, parent_id, value)
13
+
14
+ builder.add_edge(parent_id, "out", value) if sink_nodes.empty?
15
+
16
+ sink_nodes.flatten
17
+ end
18
+
19
+ private
20
+
21
+ def build_steps_graph(builder, parent_id, value)
22
+ sink_nodes = []
23
+
24
+ steps.each do |step|
25
+ sink_nodes = step.to_graph(builder, parent_id: parent_id, value: value)
26
+ value = nil # We don't want to pass the value to subsequent steps
27
+ parent_id = step.node_id
28
+
29
+ builder.add_node_to_subgraph(node_id, step.node_id)
30
+
31
+ sink_nodes.each { |sink_node| builder.add_node_to_subgraph(node_id, sink_node) }
32
+ end
33
+
34
+ [parent_id, value, sink_nodes]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ class Subgraph
7
+ attr_reader :id, :name, :nodes
8
+
9
+ def initialize(id, name, nodes)
10
+ @id = id
11
+ @name = name
12
+ @nodes = nodes
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ module Graph
6
+ def self.include_extensions
7
+ MARS::Runnable.include(Runnable)
8
+ MARS::AgentStep.include(AgentStep)
9
+ MARS::Gate.include(Gate)
10
+ MARS::Workflows::Sequential.include(SequentialWorkflow)
11
+ MARS::Workflows::Parallel.include(ParallelWorkflow)
12
+ MARS::Aggregator.include(Aggregator)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ class Html
6
+ MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"
7
+
8
+ def initialize(obj)
9
+ @mermaid = Mermaid.new(obj)
10
+ end
11
+
12
+ def render
13
+ diagram = @mermaid.graph_mermaid.join("\n")
14
+ direction = "LR"
15
+
16
+ <<~HTML
17
+ <!DOCTYPE html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22
+ <title>MARS Workflow</title>
23
+ <script src="#{MERMAID_CDN}"></script>
24
+ </head>
25
+ <body>
26
+ <pre class="mermaid">
27
+ flowchart #{direction}
28
+ #{diagram}
29
+ </pre>
30
+ <script>mermaid.initialize({ startOnLoad: true });</script>
31
+ </body>
32
+ </html>
33
+ HTML
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Rendering
5
+ class Mermaid
6
+ attr_reader :obj, :graph, :nodes, :subgraphs
7
+
8
+ def initialize(obj)
9
+ @obj = obj
10
+ @graph, @nodes, @subgraphs = obj.build_graph
11
+ end
12
+
13
+ def render(options = {})
14
+ direction = options.fetch(:direction, "LR")
15
+ mermaid = graph_mermaid.join("\n")
16
+
17
+ <<~MERMAID
18
+ ```mermaid
19
+ flowchart #{direction}
20
+ #{mermaid}
21
+ ```
22
+ MERMAID
23
+ end
24
+
25
+ def graph_mermaid
26
+ nodes_mermaid + subgraphs_mermaid + edges_mermaid
27
+ end
28
+
29
+ def nodes_mermaid
30
+ nodes.keys.map { |node_id| "#{node_id}#{shape(node_id)}" }
31
+ end
32
+
33
+ def subgraphs_mermaid
34
+ subgraphs.values.reverse.map do |subgraph|
35
+ node_names = subgraph.nodes
36
+ "subgraph #{subgraph.id}[\"#{subgraph.name}\"]\n #{node_names.join("\n ")}\nend"
37
+ end
38
+ end
39
+
40
+ def edges_mermaid
41
+ edges = []
42
+ graph.each do |from, tos|
43
+ tos.each do |to|
44
+ node_id, value = to
45
+ edges << "#{from} -->#{edge_value(value)} #{node_id}"
46
+ end
47
+ end
48
+ edges
49
+ end
50
+
51
+ def shape(node_id)
52
+ node = nodes[node_id]
53
+
54
+ case node.type
55
+ when Graph::Node::INPUT, Graph::Node::OUTPUT
56
+ "((#{node.name}))"
57
+ when Graph::Node::GATE
58
+ "{#{node.name}}"
59
+ else
60
+ "[#{node.name}]"
61
+ end
62
+ end
63
+
64
+ def edge_value(value)
65
+ return "" unless value
66
+
67
+ "|#{value}|"
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/mars/runnable.rb CHANGED
@@ -1,8 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Mars
3
+ module MARS
4
4
  class Runnable
5
- def run(input)
5
+ include Hooks
6
+
7
+ attr_reader :name, :formatter
8
+ attr_accessor :state
9
+
10
+ class << self
11
+ def step_name
12
+ return @step_name if defined?(@step_name)
13
+ return unless name
14
+
15
+ name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
16
+ end
17
+
18
+ def formatter(klass = nil)
19
+ klass ? @formatter_class = klass : @formatter_class
20
+ end
21
+ end
22
+
23
+ def initialize(name: self.class.step_name, state: {}, formatter: nil)
24
+ @name = name
25
+ @state = state
26
+ @formatter = formatter || self.class.formatter&.new || Formatter.new
27
+ end
28
+
29
+ def run(context)
6
30
  raise NotImplementedError
7
31
  end
8
32
  end
data/lib/mars/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Mars
4
- VERSION = "0.1.0"
3
+ module MARS
4
+ VERSION = "1.0.0"
5
5
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MARS
4
+ module Workflows
5
+ class AggregateError < StandardError
6
+ attr_reader :errors
7
+
8
+ def initialize(errors)
9
+ @errors = errors
10
+ super(errors.map { |error| "#{error[:step_name]}: #{error[:error].message}" }.join("\n"))
11
+ end
12
+ end
13
+ end
14
+ end