phronomy 0.10.0 → 0.11.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +35 -24
  4. data/benchmark/bench_agent_invoke.rb +1 -0
  5. data/lib/phronomy/agent/base.rb +223 -95
  6. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  7. data/lib/phronomy/agent/concerns/filterable.rb +184 -0
  8. data/lib/phronomy/agent/concerns/retryable.rb +9 -5
  9. data/lib/phronomy/agent/context/capability/base.rb +2 -2
  10. data/lib/phronomy/agent/invocation_context.rb +171 -0
  11. data/lib/phronomy/agent/invocation_session.rb +337 -0
  12. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  13. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  14. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  15. data/lib/phronomy/configuration.rb +0 -6
  16. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  17. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  18. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  19. data/lib/phronomy/engine/fsm_session.rb +248 -0
  20. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  21. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  22. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  23. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  24. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  25. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  26. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  27. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  28. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  29. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  30. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  31. data/lib/phronomy/filter/base.rb +56 -0
  32. data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
  33. data/lib/phronomy/filter.rb +5 -0
  34. data/lib/phronomy/generator_verifier.rb +21 -13
  35. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  37. data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
  38. data/lib/phronomy/tools/mcp.rb +1 -1
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  41. data/lib/phronomy/workflow.rb +6 -3
  42. data/lib/phronomy/workflow_context.rb +7 -5
  43. data/lib/phronomy/workflow_runner.rb +77 -27
  44. data/lib/phronomy.rb +24 -13
  45. data/scripts/api_snapshot.rb +2 -2
  46. metadata +42 -44
  47. data/lib/phronomy/agent/checkpoint.rb +0 -208
  48. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  49. data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
  50. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  51. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  52. data/lib/phronomy/context.rb +0 -12
  53. data/lib/phronomy/guardrail/base.rb +0 -45
  54. data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
  55. data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
  56. data/lib/phronomy/guardrail.rb +0 -7
  57. data/lib/phronomy/tool.rb +0 -9
  58. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  59. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  60. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  61. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  62. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  63. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  64. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  65. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  66. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  67. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  68. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  69. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  70. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  71. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  72. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Default in-memory idempotency store for {Checkpoint} resume operations.
6
- #
7
- # Tracks consumed checkpoint IDs so that calling {Agent::Base#resume} twice
8
- # with the same checkpoint raises {Phronomy::CheckpointAlreadyResumedError}
9
- # instead of silently executing the approved tool a second time.
10
- #
11
- # This implementation is *not thread-safe*. It assumes a single agent instance
12
- # is accessed from only one thread at a time, which is the expected usage pattern.
13
- # Agent instances themselves are not thread-safe (state like +@messages+, +@config+
14
- # is not protected), so concurrent calls to the same agent instance are unsupported.
15
- #
16
- # Each agent instance gets its own store by default, so no sharing occurs unless
17
- # the caller explicitly assigns the same store object to multiple agents.
18
- #
19
- # For distributed environments (multiple processes or background jobs), swap this
20
- # for a custom implementation backed by Redis, ActiveRecord, or another shared store.
21
- # *Your custom store implementation is responsible for ensuring thread-safety* if
22
- # your application shares the same store instance across multiple threads.
23
- #
24
- # @example Plugging in a custom store
25
- # agent = MyAgent.new
26
- # agent.checkpoint_store = MyRedis::CheckpointStore.new
27
- #
28
- # @example Duck-type contract required by any replacement
29
- # # consumed?(checkpoint_id) => Boolean
30
- # # consume!(checkpoint_id) => void; raises CheckpointAlreadyResumedError if duplicate
31
- # # cleanup!(checkpoint_id) => void (optional); removes tracking for the checkpoint
32
- # # clear! => void (optional); removes all tracked checkpoints
33
- #
34
- # @api public
35
- class CheckpointStore
36
- def initialize
37
- @consumed = Set.new
38
- end
39
-
40
- # Returns +true+ if the given checkpoint ID has already been consumed.
41
- #
42
- # @param checkpoint_id [String]
43
- # @return [Boolean]
44
- # @api public
45
- def consumed?(checkpoint_id)
46
- @consumed.include?(checkpoint_id)
47
- end
48
-
49
- # Marks +checkpoint_id+ as consumed, or raises if it was already consumed.
50
- #
51
- # @param checkpoint_id [String]
52
- # @raise [Phronomy::CheckpointAlreadyResumedError]
53
- # @return [void]
54
- # @api public
55
- def consume!(checkpoint_id)
56
- if @consumed.include?(checkpoint_id)
57
- raise Phronomy::CheckpointAlreadyResumedError,
58
- "checkpoint #{checkpoint_id} has already been resumed"
59
- end
60
- @consumed.add(checkpoint_id)
61
- nil
62
- end
63
-
64
- # Removes tracking for a specific checkpoint ID.
65
- #
66
- # Use this to explicitly discard a checkpoint when the application
67
- # determines it is no longer needed (e.g., user abandons an approval
68
- # workflow).
69
- #
70
- # This method is optional in the duck-type contract. Custom store
71
- # implementations may choose not to implement it.
72
- #
73
- # @param checkpoint_id [String]
74
- # @return [void]
75
- # @api public
76
- def cleanup!(checkpoint_id)
77
- @consumed.delete(checkpoint_id)
78
- nil
79
- end
80
-
81
- # Removes all tracked checkpoint IDs.
82
- #
83
- # Use this for test cleanup, periodic maintenance, or application
84
- # shutdown.
85
- #
86
- # This method is optional in the duck-type contract. Custom store
87
- # implementations may choose not to implement it.
88
- #
89
- # @return [void]
90
- # @api public
91
- def clear!
92
- @consumed.clear
93
- nil
94
- end
95
- end
96
- end
97
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- module Concerns
6
- # Adds input and output guardrail support to an agent.
7
- #
8
- # Included in {Phronomy::Agent::Base}. Guardrails are run on the raw
9
- # input string before the LLM is called, and on the raw output string
10
- # before the result is returned to the caller.
11
- # @api private
12
- module Guardrailable
13
- # Attach a guardrail that validates input before every #invoke call.
14
- # @param guardrail [Phronomy::Guardrail::InputGuardrail]
15
- # @return [self]
16
- # @api private
17
- def add_input_guardrail(guardrail)
18
- @input_guardrails ||= []
19
- @input_guardrails << guardrail
20
- self
21
- end
22
-
23
- # Attach a guardrail that validates output before it is returned.
24
- # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
25
- # @return [self]
26
- # @api private
27
- def add_output_guardrail(guardrail)
28
- @output_guardrails ||= []
29
- @output_guardrails << guardrail
30
- self
31
- end
32
-
33
- private
34
-
35
- def run_input_guardrails!(input)
36
- (@input_guardrails || []).each { |g| g.run!(input) }
37
- end
38
-
39
- def run_output_guardrails!(output)
40
- (@output_guardrails || []).each { |g| g.run!(output) }
41
- end
42
- end
43
- end
44
- end
45
- end
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- module Concerns
8
- # Adds suspend/resume and tool-approval support to an agent.
9
- #
10
- # Included in {Phronomy::Agent::Base}. When a tool decorated with
11
- # +requires_approval true+ is called and no synchronous approval handler
12
- # has been registered, the invocation is suspended and a
13
- # @api private
14
- # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
15
- module Suspendable
16
- # Registers a callback that is invoked before executing any tool that has
17
- # +requires_approval true+ set. The block receives the tool name (String)
18
- # and the arguments Hash, and must return a truthy value to allow execution.
19
- # Returning a falsy value causes the tool to return a denial message instead
20
- # of executing.
21
- #
22
- # When no handler is registered and a tool with +requires_approval+ is
23
- # called, #invoke returns a suspended result hash containing a
24
- # {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
25
- # obtaining an approval decision from the user or an external system.
26
- #
27
- # @example Synchronous handler
28
- # agent = MyAgent.new
29
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
30
- # @return [self]
31
- # @api private
32
- def on_approval_required(&block)
33
- @approval_handler = block
34
- self
35
- end
36
-
37
- # Registers a scope policy callable for this agent instance.
38
- #
39
- # The callable receives +(tool_class, scope, agent)+ and must return
40
- # +:allow+, +:reject+, or +:approve+.
41
- #
42
- # @example Reject all write-scoped tools
43
- # agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
44
- #
45
- # @param policy [#call]
46
- # @return [void]
47
- # @api public
48
- def scope_policy=(policy)
49
- @scope_policy = policy
50
- end
51
-
52
- # Sets the idempotency store used to guard against duplicate resumes.
53
- #
54
- # The store must respond to:
55
- # - +consumed?(checkpoint_id)+ ⇒ Boolean
56
- # - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
57
- #
58
- # Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
59
- # Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
60
- # Custom stores are responsible for ensuring thread-safety if shared across threads.
61
- #
62
- # @param store [#consumed?, #consume!]
63
- # @return [void]
64
- # @api public
65
- def checkpoint_store=(store)
66
- @checkpoint_store = store
67
- end
68
-
69
- # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
70
- #
71
- # This method reconstructs the conversation state captured at suspension
72
- # time, injects the tool result (executed or denied), and continues the
73
- # LLM loop until it produces a final answer.
74
- #
75
- # @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
76
- # the suspended #invoke call
77
- # @param approved [Boolean] +true+ to execute the pending tool; +false+
78
- # to inject a denial message and let the LLM handle it gracefully
79
- # @param config [Hash] same runtime options as #invoke
80
- # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
81
- # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
82
- # when a second approval-required tool is encountered during continuation
83
- # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
84
- # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
85
- # @api private
86
- def resume(checkpoint, approved:, config: {})
87
- # Guard against duplicate resumes using the idempotency store.
88
- _checkpoint_store.consume!(checkpoint.checkpoint_id)
89
- # Build a fresh chat with all tools registered.
90
- chat = build_chat
91
-
92
- # Re-apply system instructions and register tools so the LLM has the
93
- # same persona/context as the original invocation. build_context
94
- # includes all tool classes (static + handoff) via add_capability.
95
- context = build_context(checkpoint.original_input, messages: [])
96
- apply_instructions(chat, context[:system]) if context[:system]
97
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
98
-
99
- # Restore the full conversation (history + user + assistant with tool call).
100
- checkpoint.messages.each { |msg| chat.messages << msg }
101
-
102
- # Determine the tool result: execute it or inject a denial string.
103
- tool_result =
104
- if approved
105
- tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
106
- tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
107
- else
108
- "Tool execution denied."
109
- end
110
-
111
- # Inject the tool result so the LLM can continue.
112
- chat.add_message(
113
- role: :tool,
114
- content: tool_result.to_s,
115
- tool_call_id: checkpoint.pending_tool_call_id
116
- )
117
-
118
- # Re-register the suspension hook so that any further requires_approval
119
- # tools encountered during continuation are intercepted rather than
120
- # executed without approval (cascading / chained approval scenario).
121
- _register_suspension_hook!(chat)
122
-
123
- # Continue the LLM loop. Rescue SuspendSignal so that a second
124
- # approval-required tool produces a new checkpoint instead of running
125
- # without consent.
126
- begin
127
- response = chat.complete
128
- rescue SuspendSignal => signal
129
- new_checkpoint = Checkpoint.new(
130
- checkpoint_id: SecureRandom.uuid,
131
- agent_class: self.class.name,
132
- requested_at: Time.now.utc,
133
- thread_id: checkpoint.thread_id,
134
- original_input: checkpoint.original_input,
135
- messages: chat.messages.dup,
136
- pending_tool_name: signal.tool_name,
137
- pending_tool_args: signal.args,
138
- pending_tool_call_id: signal.tool_call_id
139
- )
140
- return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
141
- end
142
-
143
- output = response.content
144
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
145
-
146
- run_output_guardrails!(output)
147
-
148
- {output: output, suspended: false, messages: chat.messages, usage: usage}
149
- end
150
-
151
- private
152
-
153
- # Registers an on_tool_call hook on the chat object that raises SuspendSignal
154
- # when an approval-required tool is about to be executed and no synchronous
155
- # on_approval_required handler has been registered.
156
- #
157
- # Does nothing when:
158
- # - a synchronous handler is already registered (@approval_handler is set), or
159
- # - none of the agent's tools have requires_approval set.
160
- #
161
- # @param chat [RubyLLM::Chat]
162
- # @api private
163
- def _register_suspension_hook!(chat)
164
- return if @approval_handler
165
- return if self.class.tools.none? { |tc| tc.requires_approval }
166
-
167
- chat.on_tool_call do |tool_call|
168
- tool_instance = chat.tools[tool_call.name.to_sym]
169
- if tool_instance&.requires_approval
170
- raise SuspendSignal.new(
171
- tool_name: tool_call.name,
172
- args: tool_call.arguments,
173
- tool_call_id: tool_call.id
174
- )
175
- end
176
- end
177
- end
178
-
179
- # Returns the checkpoint idempotency store for this instance, lazily
180
- # initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
181
- #
182
- # @return [#consumed?, #consume!]
183
- # @api private
184
- def _checkpoint_store
185
- @checkpoint_store ||= CheckpointStore.new
186
- end
187
- end
188
- end
189
- end
190
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Raised internally inside the on_tool_call hook when an approval-required
6
- # tool is encountered and no synchronous on_approval_required handler has
7
- # been registered. Caught by Agent::Base#invoke_once to produce a
8
- # suspended result hash containing a Checkpoint.
9
- #
10
- # This class is intentionally NOT part of the public API. Callers should
11
- # @api private
12
- # inspect the +:suspended+ key in the result hash returned by #invoke.
13
- #
14
- # @api private
15
- class SuspendSignal < StandardError
16
- # @return [String] the name of the tool that triggered the suspension
17
- attr_reader :tool_name
18
-
19
- # @return [Hash] the arguments the LLM passed to the tool
20
- attr_reader :args
21
-
22
- # @return [String] the tool_call_id from the LLM response
23
- attr_reader :tool_call_id
24
-
25
- # @param tool_name [String]
26
- # @param args [Hash]
27
- # @param tool_call_id [String]
28
- # @api private
29
- def initialize(tool_name:, args:, tool_call_id:)
30
- super("Agent suspended waiting for approval of tool: #{tool_name}")
31
- @tool_name = tool_name
32
- @args = args
33
- @tool_call_id = tool_call_id
34
- end
35
- end
36
- end
37
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Context management utilities: token estimation, budget calculation, and
5
- # context assembly.
6
- #
7
- # Sub-modules are auto-loaded by Zeitwerk:
8
- # Phronomy::LlmContextWindow::TokenEstimator
9
- # Phronomy::LlmContextWindow::TokenBudget
10
- module Context
11
- end
12
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Abstract base class for all guardrails.
6
- #
7
- # Subclasses override #check to validate input or output.
8
- # Call #fail! inside #check to reject with a reason.
9
- #
10
- # @example
11
- # class NoPIIGuardrail < Phronomy::Guardrail::InputGuardrail
12
- # def check(input)
13
- # fail!("PII detected") if input.to_s.match?(/\d{3}-\d{2}-\d{4}/)
14
- # end
15
- # end
16
- class Base
17
- # Validate the value. Subclasses must implement this method.
18
- # @param value [Object] the input or output being checked
19
- # @raise [Phronomy::GuardrailError] if the guardrail rejects the value
20
- # @api public
21
- def check(value)
22
- raise NotImplementedError, "#{self.class}#check is not implemented"
23
- end
24
-
25
- # Run the check, raising GuardrailError on failure.
26
- # @param value [Object]
27
- # @return [Object] the original value (unchanged) when the check passes
28
- # @api public
29
- def run!(value)
30
- check(value)
31
- value
32
- end
33
-
34
- protected
35
-
36
- # Call inside #check to reject the value.
37
- # @param reason [String] human-readable rejection reason
38
- # @raise [Phronomy::GuardrailError]
39
- # @api public
40
- def fail!(reason)
41
- raise Phronomy::GuardrailError.new(reason, guardrail: self)
42
- end
43
- end
44
- end
45
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Guardrail applied to agent/chain input before it reaches the LLM.
6
- #
7
- # @example
8
- # class NoCreditCardGuardrail < Phronomy::Guardrail::InputGuardrail
9
- # def check(input)
10
- # fail!("Credit card numbers are not allowed") if input.to_s.match?(/\d{4}[- ]\d{4}[- ]\d{4}[- ]\d{4}/)
11
- # end
12
- # end
13
- #
14
- # agent = MyAgent.new
15
- # agent.add_input_guardrail(NoCreditCardGuardrail.new)
16
- class InputGuardrail < Base
17
- end
18
- end
19
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Guardrail applied to agent/chain output before it is returned to the caller.
6
- #
7
- # @example
8
- # class NoSecretsGuardrail < Phronomy::Guardrail::OutputGuardrail
9
- # def check(output)
10
- # fail!("Response contains a secret key") if output.to_s.match?(/sk-[A-Za-z0-9]{32,}/)
11
- # end
12
- # end
13
- #
14
- # agent = MyAgent.new
15
- # agent.add_output_guardrail(NoSecretsGuardrail.new)
16
- class OutputGuardrail < Base
17
- end
18
- end
19
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Convenience require for Guardrail sub-classes.
4
- # Zeitwerk auto-loads individual files; this is only needed for explicit requires.
5
- require_relative "guardrail/base"
6
- require_relative "guardrail/input_guardrail"
7
- require_relative "guardrail/output_guardrail"
data/lib/phronomy/tool.rb DELETED
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This file is intentionally empty.
4
- # Tool definitions have moved to Phronomy::Agent::Context::Capability.
5
- # See lib/phronomy/agent/context/capability/.
6
- module Phronomy
7
- module Tool
8
- end
9
- end