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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/lib/agent_runtime/agent.rb +150 -0
- data/lib/agent_runtime/agent_fsm.rb +446 -0
- data/lib/agent_runtime/audit_log.rb +62 -0
- data/lib/agent_runtime/decision.rb +35 -0
- data/lib/agent_runtime/errors.rb +28 -0
- data/lib/agent_runtime/executor.rb +85 -0
- data/lib/agent_runtime/fsm.rb +213 -0
- data/lib/agent_runtime/planner.rb +93 -0
- data/lib/agent_runtime/policy.rb +42 -0
- data/lib/agent_runtime/state.rb +81 -0
- data/lib/agent_runtime/tool_registry.rb +50 -0
- data/lib/agent_runtime/version.rb +8 -0
- data/lib/agent_runtime.rb +51 -0
- metadata +72 -0
|
@@ -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,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: []
|