agent_runtime 0.2.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.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Formal Finite State Machine for agentic workflows.
5
+ #
6
+ # Implements the canonical agentic workflow FSM with 8 states:
7
+ # - INTAKE: Normalize input, initialize state
8
+ # - PLAN: Single-shot planning using /generate
9
+ # - DECIDE: Make bounded decision (continue vs stop)
10
+ # - EXECUTE: LLM proposes next actions using /chat (looping state)
11
+ # - OBSERVE: Execute tools, inject real-world results
12
+ # - LOOP_CHECK: Control continuation
13
+ # - FINALIZE: Produce terminal output (terminal state)
14
+ # - HALT: Abort safely (terminal state)
15
+ #
16
+ # Valid state transitions:
17
+ # - INTAKE → PLAN
18
+ # - PLAN → DECIDE | HALT
19
+ # - DECIDE → EXECUTE | FINALIZE | HALT
20
+ # - EXECUTE → OBSERVE | FINALIZE | HALT
21
+ # - OBSERVE → LOOP_CHECK
22
+ # - LOOP_CHECK → EXECUTE | FINALIZE | HALT
23
+ # - FINALIZE → (terminal)
24
+ # - HALT → (terminal)
25
+ #
26
+ # @example Initialize and use FSM
27
+ # fsm = FSM.new(max_iterations: 100)
28
+ # fsm.transition_to(FSM::STATES[:PLAN], reason: "Starting")
29
+ # fsm.plan? # => true
30
+ #
31
+ # @see AgentFSM
32
+ class FSM
33
+ # State constants mapping state names to integer values.
34
+ #
35
+ # @return [Hash<Symbol, Integer>] Hash of state names to integer values
36
+ STATES = {
37
+ INTAKE: 0,
38
+ PLAN: 1,
39
+ DECIDE: 2,
40
+ EXECUTE: 3,
41
+ OBSERVE: 4,
42
+ LOOP_CHECK: 5,
43
+ FINALIZE: 6,
44
+ HALT: 7
45
+ }.freeze
46
+
47
+ # Terminal states that cannot transition to other states.
48
+ #
49
+ # @return [Array<Integer>] Array of terminal state values
50
+ TERMINAL_STATES = [STATES[:FINALIZE], STATES[:HALT]].freeze
51
+
52
+ # Valid state transitions based on FSM specification.
53
+ #
54
+ # Maps each state to an array of valid next states.
55
+ #
56
+ # @return [Hash<Integer, Array<Integer>>] Hash mapping state values to arrays of valid next states
57
+ VALID_TRANSITIONS = {
58
+ STATES[:INTAKE] => [STATES[:PLAN]],
59
+ STATES[:PLAN] => [STATES[:DECIDE], STATES[:HALT]],
60
+ STATES[:DECIDE] => [STATES[:EXECUTE], STATES[:FINALIZE], STATES[:HALT]],
61
+ STATES[:EXECUTE] => [STATES[:OBSERVE], STATES[:FINALIZE], STATES[:HALT]],
62
+ STATES[:OBSERVE] => [STATES[:LOOP_CHECK]],
63
+ STATES[:LOOP_CHECK] => [STATES[:EXECUTE], STATES[:FINALIZE], STATES[:HALT]],
64
+ STATES[:FINALIZE] => [],
65
+ STATES[:HALT] => []
66
+ }.freeze
67
+
68
+ # Initialize a new FSM instance.
69
+ #
70
+ # @param max_iterations [Integer] Maximum number of iterations before raising an error (default: 50)
71
+ def initialize(max_iterations: 50)
72
+ @state = STATES[:INTAKE]
73
+ @max_iterations = max_iterations
74
+ @iteration_count = 0
75
+ @history = []
76
+ end
77
+
78
+ # @!attribute [r] state
79
+ # @return [Integer] Current state value
80
+ # @!attribute [r] iteration_count
81
+ # @return [Integer] Current iteration count
82
+ # @!attribute [r] history
83
+ # @return [Array<Hash>] Array of transition history entries with :from, :to, :reason, :iteration
84
+ attr_reader :state, :iteration_count, :history
85
+
86
+ # Check if current state is INTAKE.
87
+ #
88
+ # @return [Boolean] True if in INTAKE state
89
+ def intake?
90
+ @state == STATES[:INTAKE]
91
+ end
92
+
93
+ # Check if current state is PLAN.
94
+ #
95
+ # @return [Boolean] True if in PLAN state
96
+ def plan?
97
+ @state == STATES[:PLAN]
98
+ end
99
+
100
+ # Check if current state is DECIDE.
101
+ #
102
+ # @return [Boolean] True if in DECIDE state
103
+ def decide?
104
+ @state == STATES[:DECIDE]
105
+ end
106
+
107
+ # Check if current state is EXECUTE.
108
+ #
109
+ # @return [Boolean] True if in EXECUTE state
110
+ def execute?
111
+ @state == STATES[:EXECUTE]
112
+ end
113
+
114
+ # Check if current state is OBSERVE.
115
+ #
116
+ # @return [Boolean] True if in OBSERVE state
117
+ def observe?
118
+ @state == STATES[:OBSERVE]
119
+ end
120
+
121
+ # Check if current state is LOOP_CHECK.
122
+ #
123
+ # @return [Boolean] True if in LOOP_CHECK state
124
+ def loop_check?
125
+ @state == STATES[:LOOP_CHECK]
126
+ end
127
+
128
+ # Check if current state is FINALIZE.
129
+ #
130
+ # @return [Boolean] True if in FINALIZE state
131
+ def finalize?
132
+ @state == STATES[:FINALIZE]
133
+ end
134
+
135
+ # Check if current state is HALT.
136
+ #
137
+ # @return [Boolean] True if in HALT state
138
+ def halt?
139
+ @state == STATES[:HALT]
140
+ end
141
+
142
+ # Check if current state is terminal (FINALIZE or HALT).
143
+ #
144
+ # @return [Boolean] True if in a terminal state
145
+ def terminal?
146
+ TERMINAL_STATES.include?(@state)
147
+ end
148
+
149
+ # Transition to a new state.
150
+ #
151
+ # Validates the transition and records it in history.
152
+ #
153
+ # @param new_state [Integer] The state value to transition to
154
+ # @param reason [String, nil] Optional reason for the transition (recorded in history)
155
+ # @return [void]
156
+ # @raise [ExecutionError] If the transition is invalid
157
+ #
158
+ # @example
159
+ # fsm.transition_to(FSM::STATES[:PLAN], reason: "Input normalized")
160
+ def transition_to(new_state, reason: nil)
161
+ validate_transition(@state, new_state)
162
+ @history << { from: @state, to: new_state, reason: reason, iteration: @iteration_count }
163
+ @state = new_state
164
+ end
165
+
166
+ # Increment the iteration count.
167
+ #
168
+ # @return [void]
169
+ # @raise [MaxIterationsExceeded] If iteration count exceeds maximum
170
+ def increment_iteration
171
+ @iteration_count += 1
172
+ raise MaxIterationsExceeded, "Max iterations (#{@max_iterations}) exceeded" if @iteration_count > @max_iterations
173
+ end
174
+
175
+ # Reset the FSM to initial state.
176
+ #
177
+ # Resets state to INTAKE, clears iteration count and history.
178
+ #
179
+ # @return [void]
180
+ def reset
181
+ @state = STATES[:INTAKE]
182
+ @iteration_count = 0
183
+ @history = []
184
+ end
185
+
186
+ # Get the name of the current state.
187
+ #
188
+ # @return [Symbol, String] State name symbol or "UNKNOWN" if state value is invalid
189
+ def state_name
190
+ STATES.key(@state) || "UNKNOWN"
191
+ end
192
+
193
+ # Validate a state transition.
194
+ #
195
+ # @param from [Integer] The source state value
196
+ # @param to [Integer] The target state value
197
+ # @return [void]
198
+ # @raise [ExecutionError] If the transition is invalid
199
+ def validate_transition(from, to)
200
+ return if VALID_TRANSITIONS[from]&.include?(to)
201
+
202
+ raise ExecutionError, "Invalid transition from #{state_name_for(from)} to #{state_name_for(to)}"
203
+ end
204
+
205
+ # Get the name for a state value.
206
+ #
207
+ # @param state_value [Integer] The state value
208
+ # @return [Symbol, String] State name symbol or "UNKNOWN" if state value is invalid
209
+ def state_name_for(state_value)
210
+ STATES.key(state_value) || "UNKNOWN"
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # LLM interface for planning and execution.
5
+ #
6
+ # Provides methods for interacting with the LLM client:
7
+ # - {#plan}: Single-shot planning using /generate endpoint
8
+ # - {#chat}: Chat-based execution returning content
9
+ # - {#chat_raw}: Chat-based execution returning full response with tool calls
10
+ #
11
+ # @example Initialize with schema and prompt builder
12
+ # schema = { type: "object", properties: { action: { type: "string" } } }
13
+ # builder = ->(input:, state:) { "Plan: #{input}" }
14
+ # planner = Planner.new(client: ollama_client, schema: schema, prompt_builder: builder)
15
+ #
16
+ # @example Planning a decision
17
+ # decision = planner.plan(input: "What should I do?", state: state.snapshot)
18
+ # # => #<AgentRuntime::Decision action="search", params={...}>
19
+ class Planner
20
+ # Initialize a new Planner instance.
21
+ #
22
+ # @param client [#generate, #chat, #chat_raw] The LLM client (e.g., OllamaClient)
23
+ # @param schema [Hash, nil] Optional JSON schema for structured generation (required for #plan)
24
+ # @param prompt_builder [Proc, nil] Optional proc to build prompts (required for #plan).
25
+ # Called as `prompt_builder.call(input: input, state: state)`
26
+ def initialize(client:, schema: nil, prompt_builder: nil)
27
+ @client = client
28
+ @schema = schema
29
+ @prompt_builder = prompt_builder
30
+ end
31
+
32
+ # PLAN state: Single-shot planning using /generate.
33
+ #
34
+ # Returns a structured {Decision} object based on the LLM's response.
35
+ # This method never loops and is used for one-shot planning decisions.
36
+ #
37
+ # @param input [String] The input prompt for planning
38
+ # @param state [Hash] The current state snapshot
39
+ # @return [Decision] A structured decision with action, params, and optional confidence
40
+ # @raise [ExecutionError] If schema or prompt_builder are not configured
41
+ #
42
+ # @example
43
+ # decision = planner.plan(input: "What should I do next?", state: { step: 1 })
44
+ # # => #<AgentRuntime::Decision action="search", params={query: "..."}, confidence=0.9>
45
+ def plan(input:, state:)
46
+ raise ExecutionError, "Planner requires schema and prompt_builder for plan" unless @schema && @prompt_builder
47
+
48
+ prompt = @prompt_builder.call(input: input, state: state)
49
+ raw = @client.generate(prompt: prompt, schema: @schema)
50
+
51
+ Decision.new(**raw.transform_keys(&:to_sym))
52
+ end
53
+
54
+ # EXECUTE state: Chat-based execution using /chat.
55
+ #
56
+ # Returns content by default (for simple responses without tool calls).
57
+ # Use this when you only need the text response from the LLM.
58
+ #
59
+ # Additional keyword arguments are passed through to the client.
60
+ #
61
+ # @param messages [Array<Hash>] Array of message hashes with :role and :content
62
+ # @param tools [Array<Hash>, nil] Optional array of tool definitions
63
+ # @return [String, Hash] The chat response content (format depends on client)
64
+ #
65
+ # @example
66
+ # messages = [{ role: "user", content: "Hello" }]
67
+ # response = planner.chat(messages: messages)
68
+ # # => "Hello! How can I help you?"
69
+ def chat(messages:, tools: nil, **)
70
+ @client.chat(messages: messages, tools: tools, allow_chat: true, **)
71
+ end
72
+
73
+ # EXECUTE state: Chat with full response (for tool calling).
74
+ #
75
+ # Returns full response including tool_calls. Use this when you need
76
+ # to extract tool calls from the LLM's response.
77
+ #
78
+ # Additional keyword arguments are passed through to the client.
79
+ #
80
+ # @param messages [Array<Hash>] Array of message hashes with :role and :content
81
+ # @param tools [Array<Hash>, nil] Optional array of tool definitions
82
+ # @return [Hash] Full response hash including message and tool_calls
83
+ #
84
+ # @example
85
+ # messages = [{ role: "user", content: "Search for weather" }]
86
+ # tools = [{ type: "function", function: { name: "search", ... } }]
87
+ # response = planner.chat_raw(messages: messages, tools: tools)
88
+ # # => { message: { content: "...", tool_calls: [...] } }
89
+ def chat_raw(messages:, tools: nil, **)
90
+ @client.chat_raw(messages: messages, tools: tools, allow_chat: true, **)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Validates agent decisions before execution.
5
+ #
6
+ # This class enforces policy constraints on decisions made by the planner.
7
+ # By default, it validates that:
8
+ # - The decision has an action
9
+ # - The confidence (if present) is at least 0.5
10
+ #
11
+ # Subclass this to implement custom validation logic.
12
+ #
13
+ # @example Basic usage
14
+ # policy = Policy.new
15
+ # policy.validate!(decision, state: state)
16
+ #
17
+ # @example Custom policy subclass
18
+ # class CustomPolicy < Policy
19
+ # def validate!(decision, state:)
20
+ # super
21
+ # raise PolicyViolation, "Action not allowed" if decision.action == "delete"
22
+ # end
23
+ # end
24
+ class Policy
25
+ # Validate a decision against policy constraints.
26
+ #
27
+ # @param decision [Decision] The decision to validate
28
+ # @param state [State, Hash, nil] The current state (unused in default implementation)
29
+ # @return [void]
30
+ # @raise [PolicyViolation] If the decision violates policy constraints:
31
+ # - Missing action
32
+ # - Confidence below 0.5 (if confidence is present)
33
+ #
34
+ # @example
35
+ # policy.validate!(decision, state: state)
36
+ # # => nil (raises PolicyViolation on failure)
37
+ def validate!(decision, state: nil) # rubocop:disable Lint/UnusedMethodArgument
38
+ raise PolicyViolation, "Missing action" unless decision.action
39
+ raise PolicyViolation, "Low confidence" if decision.confidence && decision.confidence < 0.5
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Explicit, serializable state management with deep merge support.
5
+ #
6
+ # This class manages the agent's state throughout execution. State is stored
7
+ # as a hash and can be snapshotted for read-only access. Updates are applied
8
+ # using deep merge to preserve nested structures.
9
+ #
10
+ # @example Initialize with initial data
11
+ # state = State.new({ step: 1, context: { user: "Alice" } })
12
+ #
13
+ # @example Take a snapshot
14
+ # snapshot = state.snapshot
15
+ # # => { step: 1, context: { user: "Alice" } }
16
+ #
17
+ # @example Apply updates
18
+ # state.apply!({ step: 2, context: { task: "search" } })
19
+ # state.snapshot
20
+ # # => { step: 2, context: { user: "Alice", task: "search" } }
21
+ class State
22
+ # Initialize a new State instance.
23
+ #
24
+ # @param data [Hash] Initial state data (default: {})
25
+ def initialize(data = {})
26
+ @data = data
27
+ end
28
+
29
+ # Create a snapshot of the current state.
30
+ #
31
+ # Returns a shallow copy of the state data. Modifications to the snapshot
32
+ # will not affect the original state.
33
+ #
34
+ # @return [Hash] A copy of the current state data
35
+ #
36
+ # @example
37
+ # snapshot = state.snapshot
38
+ # snapshot[:new_key] = "value" # Does not modify state
39
+ def snapshot
40
+ @data.dup
41
+ end
42
+
43
+ # Apply a result hash to the state using deep merge.
44
+ #
45
+ # Merges the result hash into the current state, preserving nested structures.
46
+ # If result is not a hash, this method does nothing.
47
+ #
48
+ # @param result [Hash, Object] The result to merge into state (must be a Hash to apply)
49
+ # @return [void]
50
+ #
51
+ # @example
52
+ # state = State.new({ a: 1, nested: { x: 10 } })
53
+ # state.apply!({ b: 2, nested: { y: 20 } })
54
+ # state.snapshot
55
+ # # => { a: 1, b: 2, nested: { x: 10, y: 20 } }
56
+ def apply!(result)
57
+ return unless result.is_a?(Hash)
58
+
59
+ deep_merge!(@data, result)
60
+ end
61
+
62
+ private
63
+
64
+ # Deep merge source hash into target hash.
65
+ #
66
+ # Recursively merges nested hashes, overwriting non-hash values.
67
+ #
68
+ # @param target [Hash] The target hash to merge into (modified in place)
69
+ # @param source [Hash] The source hash to merge from
70
+ # @return [void]
71
+ def deep_merge!(target, source)
72
+ source.each do |key, value|
73
+ if target[key].is_a?(Hash) && value.is_a?(Hash)
74
+ deep_merge!(target[key], value)
75
+ else
76
+ target[key] = value
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Registry mapping tool names to Ruby callables.
5
+ #
6
+ # This class maintains a registry of available tools that can be called
7
+ # by the executor. Tools are registered as callable objects (procs, lambdas, or objects responding to #call).
8
+ #
9
+ # @example Initialize with tools
10
+ # tools = {
11
+ # "search" => ->(query:) { "Results for #{query}" },
12
+ # "calculate" => Calculator.new
13
+ # }
14
+ # registry = ToolRegistry.new(tools)
15
+ #
16
+ # @example Call a tool
17
+ # result = registry.call("search", { query: "weather" })
18
+ # # => "Results for weather"
19
+ class ToolRegistry
20
+ # Initialize a new ToolRegistry instance.
21
+ #
22
+ # @param tools [Hash<String, #call>] Hash mapping tool names to callable objects
23
+ #
24
+ # @example
25
+ # registry = ToolRegistry.new({
26
+ # "search" => ->(query:) { search_api(query) },
27
+ # "email" => EmailTool.new
28
+ # })
29
+ def initialize(tools = {})
30
+ @tools = tools
31
+ end
32
+
33
+ # Call a tool by name with the given parameters.
34
+ #
35
+ # @param action [String, Symbol] The name of the tool to call
36
+ # @param params [Hash] Parameters to pass to the tool (will be keyword-argument expanded)
37
+ # @return [Object] The result of calling the tool
38
+ # @raise [ToolNotFound] If the tool is not registered
39
+ #
40
+ # @example
41
+ # result = registry.call("search", { query: "weather", limit: 10 })
42
+ # # Calls: search_tool.call(query: "weather", limit: 10)
43
+ def call(action, params)
44
+ tool = @tools[action]
45
+ raise ToolNotFound, "Tool not found: #{action}" unless tool
46
+
47
+ tool.call(**params)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Current version of the AgentRuntime gem.
5
+ #
6
+ # @return [String] Version string in semantic versioning format
7
+ VERSION = "0.2.0"
8
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ollama_client"
4
+
5
+ require_relative "agent_runtime/version"
6
+ require_relative "agent_runtime/agent"
7
+ require_relative "agent_runtime/agent_fsm"
8
+ require_relative "agent_runtime/planner"
9
+ require_relative "agent_runtime/policy"
10
+ require_relative "agent_runtime/executor"
11
+ require_relative "agent_runtime/state"
12
+ require_relative "agent_runtime/decision"
13
+ require_relative "agent_runtime/tool_registry"
14
+ require_relative "agent_runtime/audit_log"
15
+ require_relative "agent_runtime/errors"
16
+ require_relative "agent_runtime/fsm"
17
+
18
+ # AgentRuntime provides a deterministic, policy-driven runtime for building
19
+ # tool-using LLM agents with explicit state, policy enforcement, and auditability.
20
+ #
21
+ # The library provides two main agent implementations:
22
+ # - {Agent}: Simple step-by-step execution with multi-step loops
23
+ # - {AgentFSM}: Formal finite state machine implementation following the canonical agentic workflow
24
+ #
25
+ # @example Basic usage with Agent
26
+ # planner = AgentRuntime::Planner.new(client: ollama_client, schema: schema, prompt_builder: builder)
27
+ # policy = AgentRuntime::Policy.new
28
+ # executor = AgentRuntime::Executor.new(tool_registry: tools)
29
+ # state = AgentRuntime::State.new
30
+ # agent = AgentRuntime::Agent.new(planner: planner, policy: policy, executor: executor, state: state)
31
+ # result = agent.run(initial_input: "What is the weather?")
32
+ #
33
+ # @example Using AgentFSM for formal workflows
34
+ # agent_fsm = AgentRuntime::AgentFSM.new(
35
+ # planner: planner,
36
+ # policy: policy,
37
+ # executor: executor,
38
+ # state: state,
39
+ # tool_registry: tools
40
+ # )
41
+ # result = agent_fsm.run(initial_input: "Analyze this data")
42
+ #
43
+ # @see Agent
44
+ # @see AgentFSM
45
+ # @see Planner
46
+ # @see Policy
47
+ # @see Executor
48
+ # @see State
49
+ # @see ToolRegistry
50
+ module AgentRuntime
51
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agent_runtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Shubham Taywade
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ollama-client
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ description: AgentRuntime provides a reusable control plane for building tool-using
27
+ LLM agents with explicit state, policy enforcement, and auditability.
28
+ email:
29
+ - shubhamtaywade82@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - lib/agent_runtime.rb
38
+ - lib/agent_runtime/agent.rb
39
+ - lib/agent_runtime/agent_fsm.rb
40
+ - lib/agent_runtime/audit_log.rb
41
+ - lib/agent_runtime/decision.rb
42
+ - lib/agent_runtime/errors.rb
43
+ - lib/agent_runtime/executor.rb
44
+ - lib/agent_runtime/fsm.rb
45
+ - lib/agent_runtime/planner.rb
46
+ - lib/agent_runtime/policy.rb
47
+ - lib/agent_runtime/state.rb
48
+ - lib/agent_runtime/tool_registry.rb
49
+ - lib/agent_runtime/version.rb
50
+ homepage: https://github.com/shubhamtaywade/agent-runtime
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.2'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.3
70
+ specification_version: 4
71
+ summary: Deterministic, policy-driven runtime for safe LLM agents
72
+ test_files: []