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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d60d801b991450361a87cd0ae9ed9d254f58f836f3bfadeff35c52696f4ac38d
4
+ data.tar.gz: b8dbd1232137bd1b8746fb8772f27f2ab4664923dc3370e5be1c702875f0abf3
5
+ SHA512:
6
+ metadata.gz: 87aeb7ede07631a7dc9bedf7db9549e7504a16dadc5d90818be64be67df1ddcd332fe2bf50889a7cfad5184da1f2a4a575d5f145f752b0a40eb770b7eb1ad4d8
7
+ data.tar.gz: b2b7fcf7d975a8faf5398fe17ba47d5682bf7fdec2a288497cf85497c9709f4eb2dbac9ac0d844777c897a032d175075c6741e57aaf2868aec7f2c554445db5c
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.2.0] - 2026-01-XX
4
+
5
+ ### Fixed
6
+ - FSM finalization now properly executes `handle_finalize` or `handle_halt` before returning
7
+ - Agent#run now always returns the last tool result when terminating
8
+ - Tool call argument parsing now handles JSON parse failures gracefully
9
+ - Audit logging now handles nil decisions without crashing
10
+
11
+ ### Changed
12
+ - Removed domain-specific code (DhanHQ helpers) from `lib/` directory
13
+ - Planning contract for FSM is now documented and consistent
14
+ - Tool calls are now enabled in FSM with basic tool definition conversion
15
+ - State#apply! now performs deep merge of nested hashes
16
+ - Removed hardcoded local paths from examples (now uses environment variables)
17
+
18
+ ### Documentation
19
+ - Updated README to remove references to deleted console helpers
20
+ - Fixed documentation to match actual behavior
21
+ - Removed CONSOLE_TESTING.md (domain-specific content)
22
+
23
+ ## [0.1.0] - 2026-01-15
24
+
25
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Shubham Taywade
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # AgentRuntime
2
+
3
+ Deterministic, policy-driven runtime for tool-using LLM agents in Ruby.
4
+
5
+ AgentRuntime is a control plane. It coordinates planning, policy validation,
6
+ tool execution, and explicit state. It does not ship domain logic.
7
+
8
+ ## What this gem is
9
+ - A small runtime to coordinate LLM decisions with Ruby tool execution.
10
+ - A formal FSM workflow (`AgentFSM`) with explicit states and history.
11
+
12
+ ## What this gem is not
13
+ - Not a domain toolkit (no broker APIs, HTTP clients, or storage).
14
+ - Not a prompt library.
15
+ - Not a memory system.
16
+
17
+ ## Strict usage rules (non-negotiable)
18
+ - Use `/generate` only for planning/decision outputs (`Planner#plan`).
19
+ - Use `/chat` only during execution/finalization (`Planner#chat_raw`, `Planner#chat`).
20
+ - The LLM never executes tools. Tools are Ruby callables and run in `Executor`.
21
+ - Tool results are injected as `role: "tool"` messages only after execution.
22
+ - Only `EXECUTE` loops. All other states are single-shot.
23
+ - Termination happens only on explicit signals:
24
+ `decision.action == "finish"`, `result[:done] == true`, or `MaxIterationsExceeded`.
25
+ - This gem does not add retries or streaming. Retry/streaming policy lives in
26
+ `ollama-client`.
27
+
28
+ If you violate any rule above, you are not using this gem correctly.
29
+
30
+ ## Narrative overview (kept here, kept strict)
31
+ AgentRuntime is a domain-agnostic runtime that separates reasoning from
32
+ authority:
33
+ - LLM reasoning happens via `Planner` only.
34
+ - Ruby owns policy and execution.
35
+ - Tools are gated and executed outside the LLM.
36
+ - State is explicit and inspectable.
37
+ - Failures are visible via explicit errors and optional audit logs.
38
+
39
+ Architecture (conceptual):
40
+ Your application → AgentRuntime → `ollama-client` → Ollama server
41
+
42
+ This overview is informative only. The strict rules above are the contract.
43
+
44
+ ## Core components (SRP map)
45
+ - `Planner`: LLM interface (`generate`, `chat`, `chat_raw`). No tools. No side effects.
46
+ - `Policy`: validates decisions before execution.
47
+ - `Executor`: executes tools via `ToolRegistry` only.
48
+ - `ToolRegistry`: maps tool names to Ruby callables.
49
+ - `State`: explicit, serializable state.
50
+ - `Agent`: simple decision loop using `Planner#plan` and tools.
51
+ - `AgentFSM`: formal FSM with explicit states and transition history.
52
+ - `AuditLog`: optional logging of decisions and results.
53
+
54
+ ## API mapping
55
+ | Concern | Method | LLM endpoint | Where it belongs |
56
+ | --- | --- | --- | --- |
57
+ | Planning / decisions | `Planner#plan` | `/api/generate` | PLAN |
58
+ | Execution / tool calls | `Planner#chat_raw` | `/api/chat` | EXECUTE |
59
+ | Final response (optional) | `Planner#chat` | `/api/chat` | FINALIZE |
60
+
61
+ `Executor` never calls the LLM.
62
+
63
+ ## Prerequisites
64
+ `agent_runtime` depends on `ollama-client`. See `PREREQUISITES.md`.
65
+
66
+ ## Installation
67
+ Add this line to your application's Gemfile:
68
+
69
+ ```ruby
70
+ gem "agent_runtime"
71
+ ```
72
+
73
+ And then execute:
74
+
75
+ ```bash
76
+ bundle install
77
+ ```
78
+
79
+ Or install it yourself as:
80
+
81
+ ```bash
82
+ gem install agent_runtime
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ### Single-step agent (`Agent#step`)
88
+ Use this for one-shot decisions or when you control the loop externally.
89
+
90
+ ```ruby
91
+ require "agent_runtime"
92
+ require "ollama_client"
93
+
94
+ tools = AgentRuntime::ToolRegistry.new({
95
+ "fetch" => ->(**args) { { data: "fetched", args: args } },
96
+ "execute" => ->(**args) { { result: "executed", args: args } }
97
+ })
98
+
99
+ client = Ollama::Client.new
100
+
101
+ schema = {
102
+ "type" => "object",
103
+ "required" => ["action", "params", "confidence"],
104
+ "properties" => {
105
+ "action" => { "type" => "string", "enum" => ["fetch", "execute", "finish"] },
106
+ "params" => { "type" => "object", "additionalProperties" => true },
107
+ "confidence" => { "type" => "number", "minimum" => 0, "maximum" => 1 }
108
+ }
109
+ }
110
+
111
+ planner = AgentRuntime::Planner.new(
112
+ client: client,
113
+ schema: schema,
114
+ prompt_builder: ->(input:, state:) {
115
+ "User request: #{input}\nContext: #{state.to_json}"
116
+ }
117
+ )
118
+
119
+ agent = AgentRuntime::Agent.new(
120
+ planner: planner,
121
+ policy: AgentRuntime::Policy.new,
122
+ executor: AgentRuntime::Executor.new(tool_registry: tools),
123
+ state: AgentRuntime::State.new,
124
+ audit_log: AgentRuntime::AuditLog.new
125
+ )
126
+
127
+ result = agent.step(input: "Fetch market data for AAPL")
128
+ puts result.inspect
129
+ ```
130
+
131
+ ### Multi-step loop (`Agent#run`)
132
+ Use this when the agent should iterate until it emits `finish` or a tool marks
133
+ `done: true`. This loop uses `/generate` only (no chat).
134
+
135
+ ```ruby
136
+ result = agent.run(initial_input: "Find best PDF library for Ruby")
137
+ ```
138
+
139
+ ### Formal FSM workflow (`AgentFSM`)
140
+ `AgentFSM` is the explicit FSM driver. It uses `/generate` for PLAN and
141
+ `/chat` for EXECUTE. Tool execution happens only in OBSERVE.
142
+
143
+ Tool calling in EXECUTE requires Ollama tool definitions. This gem does not
144
+ auto-convert `ToolRegistry` entries to `Ollama::Tool` objects. If you need tool
145
+ calling, subclass `AgentFSM` and return tool definitions from
146
+ `build_tools_for_chat`.
147
+
148
+ ```ruby
149
+ class MyAgentFSM < AgentRuntime::AgentFSM
150
+ def build_tools_for_chat
151
+ # Return Ollama::Tool definitions here
152
+ []
153
+ end
154
+ end
155
+
156
+ agent_fsm = MyAgentFSM.new(
157
+ planner: planner,
158
+ policy: AgentRuntime::Policy.new,
159
+ executor: AgentRuntime::Executor.new(tool_registry: tools),
160
+ state: AgentRuntime::State.new,
161
+ tool_registry: tools,
162
+ audit_log: AgentRuntime::AuditLog.new
163
+ )
164
+
165
+ result = agent_fsm.run(initial_input: "Research Ruby memory management")
166
+ ```
167
+
168
+ ## Tool safety model
169
+ - Tools are Ruby callables registered in `ToolRegistry`.
170
+ - LLM output never executes tools directly.
171
+ - Tool execution happens only in `Executor`.
172
+ - Tool results are recorded in state and (for FSM) injected as `role: "tool"`.
173
+
174
+ ## Examples
175
+
176
+ ### Quick Start
177
+ **Start here**: `examples/complete_working_example.rb` - A complete, runnable example demonstrating all features.
178
+
179
+ ```bash
180
+ # Make sure Ollama is running: ollama serve
181
+ ruby examples/complete_working_example.rb
182
+ ```
183
+
184
+ ### Available Examples
185
+ - `examples/complete_working_example.rb` ⭐ - **Complete working example** (recommended starting point)
186
+ - `examples/fixed_console_example.rb` - Minimal example for console use
187
+ - `examples/console_example.rb` - Basic console example
188
+ - `examples/rails_example/` - Rails integration example
189
+ - `examples/dhanhq_example.rb` - Domain-specific example (requires DhanHQ gem)
190
+
191
+ See `examples/README.md` for detailed documentation on all examples.
192
+
193
+ Examples are not part of the public API.
194
+
195
+ ## Documentation
196
+ - `AGENTIC_WORKFLOWS.md`
197
+ - `FSM_WORKFLOWS.md`
198
+ - `SCHEMA_GUIDE.md`
199
+ - `PREREQUISITES.md`
200
+
201
+ ## Development
202
+ After checking out the repo, run:
203
+
204
+ ```bash
205
+ bin/setup
206
+ ```
207
+
208
+ To run tests:
209
+
210
+ ```bash
211
+ rake spec
212
+ # or
213
+ bundle exec rspec
214
+ ```
215
+
216
+ Test coverage reports are generated automatically. View the HTML report:
217
+ ```bash
218
+ open coverage/index.html # macOS
219
+ xdg-open coverage/index.html # Linux
220
+ ```
221
+
222
+ See `TESTING.md` for detailed testing and coverage information.
223
+
224
+ To run the console:
225
+
226
+ ```bash
227
+ bin/console
228
+ ```
229
+
230
+ ## Contributing
231
+ Bug reports and pull requests are welcome. Keep the API strict and small.
232
+
233
+ ## License
234
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentRuntime
4
+ # Simple agent implementation with step-by-step execution and multi-step loops.
5
+ #
6
+ # This class provides a straightforward agent implementation that executes
7
+ # planning, validation, and execution steps in a loop until termination.
8
+ # Use this for simpler workflows where you don't need the full FSM structure.
9
+ #
10
+ # @example Single step execution
11
+ # agent = AgentRuntime::Agent.new(planner: planner, policy: policy, executor: executor, state: state)
12
+ # result = agent.step(input: "What is 2+2?")
13
+ #
14
+ # @example Multi-step agentic workflow
15
+ # agent = AgentRuntime::Agent.new(planner: planner, policy: policy, executor: executor, state: state)
16
+ # result = agent.run(initial_input: "Find the weather and send an email")
17
+ class Agent
18
+ # Initialize a new Agent instance.
19
+ #
20
+ # @param planner [Planner] The planner responsible for generating decisions
21
+ # @param policy [Policy] The policy validator for decisions
22
+ # @param executor [Executor] The executor for tool calls
23
+ # @param state [State] The state manager for agent state
24
+ # @param audit_log [AuditLog, nil] Optional audit logger for recording decisions
25
+ # @param max_iterations [Integer] Maximum number of iterations before raising an error (default: 50)
26
+ def initialize(planner:, policy:, executor:, state:, audit_log: nil, max_iterations: 50)
27
+ @planner = planner
28
+ @policy = policy
29
+ @executor = executor
30
+ @state = state
31
+ @audit_log = audit_log
32
+ @max_iterations = max_iterations
33
+ end
34
+
35
+ # Single step execution (non-agentic).
36
+ #
37
+ # Use this for one-shot decisions or when you control the loop externally.
38
+ # This method performs a single planning, validation, execution, and state update cycle.
39
+ #
40
+ # @param input [String] The input prompt for this step
41
+ # @return [Hash] The execution result hash
42
+ # @raise [PolicyViolation] If the decision violates policy constraints
43
+ # @raise [ExecutionError] If execution fails
44
+ #
45
+ # @example
46
+ # result = agent.step(input: "Calculate 5 * 10")
47
+ # # => { result: 50 }
48
+ def step(input:)
49
+ decision = @planner.plan(
50
+ input: input,
51
+ state: @state.snapshot
52
+ )
53
+
54
+ @policy.validate!(decision, state: @state)
55
+
56
+ result = @executor.execute(decision, state: @state)
57
+
58
+ @state.apply!(result)
59
+
60
+ @audit_log&.record(
61
+ input: input,
62
+ decision: decision,
63
+ result: result
64
+ )
65
+
66
+ result
67
+ end
68
+
69
+ # Agentic workflow loop (runs until termination).
70
+ #
71
+ # Use this for multi-step workflows where the agent decides when to stop.
72
+ # The loop continues until:
73
+ # - The decision action is "finish"
74
+ # - The result contains `done: true`
75
+ # - Maximum iterations are exceeded
76
+ #
77
+ # @param initial_input [String] The initial input to start the workflow
78
+ # @param input_builder [Proc, nil] Optional proc to build next input from result and iteration.
79
+ # Called as `input_builder.call(result, iteration)`. If nil, uses default builder.
80
+ # @return [Hash] Final result hash, always includes `done: true` and `iterations` count
81
+ # @raise [MaxIterationsExceeded] If maximum iterations are exceeded
82
+ # @raise [PolicyViolation] If any decision violates policy constraints
83
+ # @raise [ExecutionError] If execution fails
84
+ #
85
+ # @example
86
+ # result = agent.run(initial_input: "Find weather and send email")
87
+ # # => { done: true, iterations: 3, ... }
88
+ #
89
+ # @example With custom input builder
90
+ # builder = ->(result, iteration) { "Iteration #{iteration}: #{result.inspect}" }
91
+ # result = agent.run(initial_input: "Start", input_builder: builder)
92
+ def run(initial_input:, input_builder: nil)
93
+ iteration = 0
94
+ current_input = initial_input
95
+ final_result = nil
96
+
97
+ loop do
98
+ iteration += 1
99
+
100
+ raise MaxIterationsExceeded, "Max iterations (#{@max_iterations}) exceeded" if iteration > @max_iterations
101
+
102
+ decision = @planner.plan(
103
+ input: current_input,
104
+ state: @state.snapshot
105
+ )
106
+
107
+ @policy.validate!(decision, state: @state)
108
+
109
+ result = @executor.execute(decision, state: @state)
110
+
111
+ @state.apply!(result)
112
+
113
+ @audit_log&.record(
114
+ input: current_input,
115
+ decision: decision,
116
+ result: result
117
+ )
118
+
119
+ # Always set final_result before checking termination
120
+ final_result = result
121
+
122
+ break if terminated?(decision, result)
123
+
124
+ current_input = input_builder ? input_builder.call(result, iteration) : build_next_input(result, iteration)
125
+ end
126
+
127
+ final_result || { done: true, iterations: iteration }
128
+ end
129
+
130
+ private
131
+
132
+ # Check if the agent should terminate based on decision and result.
133
+ #
134
+ # @param decision [Decision] The current decision
135
+ # @param result [Hash] The execution result
136
+ # @return [Boolean] True if the agent should terminate
137
+ def terminated?(decision, result)
138
+ decision.action == "finish" || result[:done] == true
139
+ end
140
+
141
+ # Build the next input for the loop iteration.
142
+ #
143
+ # @param result [Hash] The previous execution result
144
+ # @param _iteration [Integer] The current iteration number (unused)
145
+ # @return [String] The next input string
146
+ def build_next_input(result, _iteration)
147
+ "Continue based on: #{result.inspect}"
148
+ end
149
+ end
150
+ end