phronomy 0.19.0 → 0.21.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 +4 -4
- data/.mutant.yml +3 -1
- data/CHANGELOG.md +41 -11
- data/CONTRIBUTING.md +74 -6
- data/README.md +15 -6
- data/docs/decisions/010-cooperative-first-concurrency.md +86 -69
- data/docs/decisions/014-unified-persistence-durable-state.md +5 -0
- data/docs/decisions/015-tool-public-facade-and-rbs-boundary.md +184 -0
- data/docs/features.md +31 -2
- data/docs/getting-started.md +10 -1
- data/docs/migrations/0.19.md +12 -6
- data/docs/persistence-backends.md +504 -0
- data/docs/runtime-and-concurrency.md +110 -147
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/agent/tool_executor.rb +7 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +142 -245
- data/lib/phronomy/engine/task.rb +50 -6
- data/lib/phronomy/invocation_context.rb +11 -52
- data/lib/phronomy/llm_adapter/base.rb +29 -32
- data/lib/phronomy/llm_adapter/ruby_llm.rb +13 -12
- data/lib/phronomy/llm_adapter.rb +10 -7
- data/lib/phronomy/output_parser/base.rb +5 -1
- data/lib/phronomy/persistence.rb +101 -7
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- data/lib/phronomy/tool/base.rb +15 -0
- data/lib/phronomy/tool.rb +11 -0
- data/lib/phronomy/vector_store/async_backend.rb +15 -38
- data/lib/phronomy/vector_store/base.rb +12 -13
- data/lib/phronomy/vector_store/embeddings/base.rb +11 -9
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +6 -0
- data/scripts/run_mutation.sh +2 -1
- data/sig/phronomy/agent.rbs +36 -0
- data/sig/phronomy/extensions.rbs +50 -0
- data/sig/phronomy/llm_adapter.rbs +11 -0
- data/sig/phronomy/persistence.rbs +70 -0
- data/sig/phronomy/runtime.rbs +43 -0
- data/sig/phronomy/tool.rbs +39 -0
- data/sig/phronomy/workflow.rbs +30 -0
- data/sig/phronomy.rbs +49 -1
- metadata +20 -3
- data/scripts/check_private_enforcement.rb +0 -93
data/lib/phronomy/engine/task.rb
CHANGED
|
@@ -3,20 +3,29 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# A thread-free asynchronous completion handle.
|
|
5
5
|
#
|
|
6
|
-
# Task
|
|
7
|
-
# OffloadPool. Task
|
|
8
|
-
#
|
|
6
|
+
# Task does not execute work. Execution belongs to EventLoop/FSMSession or
|
|
7
|
+
# OffloadPool. Task represents completion, failure, cancellation, callbacks,
|
|
8
|
+
# and a blocking wait for callers outside EventLoop.
|
|
9
|
+
#
|
|
10
|
+
# Framework components own Task settlement. Application code should observe a
|
|
11
|
+
# Task through {#wait_result}, {#on_complete}, {#map}, and state readers rather
|
|
12
|
+
# than calling {#complete}, {#fail}, or {#cancel!}. Operation-wide cancellation
|
|
13
|
+
# is supplied through the CancellationToken accepted by the API that created
|
|
14
|
+
# the Task.
|
|
9
15
|
class Task
|
|
10
16
|
STATES = %i[pending completed failed cancelled].freeze
|
|
11
17
|
TERMINAL_STATES = %i[completed failed cancelled].freeze
|
|
12
18
|
private_constant :TERMINAL_STATES
|
|
13
19
|
|
|
20
|
+
# Creates an unsettled completion handle for framework-owned execution.
|
|
21
|
+
# @api private
|
|
14
22
|
def self.deferred(name: nil, parent: nil)
|
|
15
23
|
new(name: name, parent: parent)
|
|
16
24
|
end
|
|
17
25
|
|
|
18
26
|
attr_reader :name, :parent
|
|
19
27
|
|
|
28
|
+
# @api private
|
|
20
29
|
def initialize(name: nil, parent: nil)
|
|
21
30
|
@name = name
|
|
22
31
|
@parent = parent
|
|
@@ -30,20 +39,35 @@ module Phronomy
|
|
|
30
39
|
parent&.register_child(self)
|
|
31
40
|
end
|
|
32
41
|
|
|
42
|
+
# @return [Symbol] :pending, :completed, :failed, or :cancelled
|
|
43
|
+
# @api public
|
|
33
44
|
def status
|
|
34
45
|
@mutex.synchronize { @status }
|
|
35
46
|
end
|
|
36
47
|
|
|
48
|
+
# @return [Boolean] whether the Task has reached a terminal state
|
|
49
|
+
# @api public
|
|
37
50
|
def done?
|
|
38
51
|
@mutex.synchronize { TERMINAL_STATES.include?(@status) }
|
|
39
52
|
end
|
|
40
53
|
|
|
54
|
+
# @return [Boolean] whether the Task has not yet reached a terminal state
|
|
55
|
+
# @api public
|
|
41
56
|
def alive?
|
|
42
57
|
!done?
|
|
43
58
|
end
|
|
44
59
|
|
|
45
|
-
# Blocks the calling thread until settlement.
|
|
46
|
-
#
|
|
60
|
+
# Blocks the calling thread until settlement.
|
|
61
|
+
#
|
|
62
|
+
# EventLoop is never allowed to wait for a Task; framework continuation must
|
|
63
|
+
# proceed through explicit events. The optional timeout is waiter-local: it
|
|
64
|
+
# does not settle or cancel the Task.
|
|
65
|
+
#
|
|
66
|
+
# @param timeout [Numeric, nil] maximum seconds this caller will block
|
|
67
|
+
# @return [Object] the completed value
|
|
68
|
+
# @raise [Phronomy::TimeoutError] when the waiter-local timeout expires
|
|
69
|
+
# @raise [Exception] the error that settled the Task
|
|
70
|
+
# @api public
|
|
47
71
|
def wait_result(timeout: nil)
|
|
48
72
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
49
73
|
raise Phronomy::EventLoopReentrancyError,
|
|
@@ -71,8 +95,9 @@ module Phronomy
|
|
|
71
95
|
value
|
|
72
96
|
end
|
|
73
97
|
|
|
74
|
-
# Compatibility wait that does not re-raise the
|
|
98
|
+
# Compatibility wait that does not re-raise the Task error.
|
|
75
99
|
# Returns self when settled, nil on timeout.
|
|
100
|
+
# @api private
|
|
76
101
|
def join(limit = nil)
|
|
77
102
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
78
103
|
raise Phronomy::EventLoopReentrancyError,
|
|
@@ -96,8 +121,15 @@ module Phronomy
|
|
|
96
121
|
|
|
97
122
|
# Registers an independent completion notification.
|
|
98
123
|
#
|
|
124
|
+
# The callback execution thread is not guaranteed. It may be the caller that
|
|
125
|
+
# registers after settlement, an OffloadPool worker, or a framework control
|
|
126
|
+
# thread. Callbacks must therefore be thread-safe and should complete quickly.
|
|
99
127
|
# A callback failure is logged and does not suppress delivery to other
|
|
100
128
|
# completion callbacks or change the Task's already-settled result.
|
|
129
|
+
#
|
|
130
|
+
# @yield [value, error]
|
|
131
|
+
# @return [self]
|
|
132
|
+
# @api public
|
|
101
133
|
def on_complete(&callback)
|
|
102
134
|
raise ArgumentError, "on_complete requires a block" unless callback
|
|
103
135
|
|
|
@@ -113,15 +145,25 @@ module Phronomy
|
|
|
113
145
|
self
|
|
114
146
|
end
|
|
115
147
|
|
|
148
|
+
# Settles this Task successfully. Framework-owned settlement API.
|
|
149
|
+
# @api private
|
|
116
150
|
def complete(value = nil)
|
|
117
151
|
settle!(:completed, value: value)
|
|
118
152
|
end
|
|
119
153
|
|
|
154
|
+
# Settles this Task with a failure. Framework-owned settlement API.
|
|
155
|
+
# @api private
|
|
120
156
|
def fail(error)
|
|
121
157
|
raise ArgumentError, "error is required" unless error
|
|
122
158
|
settle!(:failed, error: error)
|
|
123
159
|
end
|
|
124
160
|
|
|
161
|
+
# Settles this Task as cancelled. Framework-owned settlement API.
|
|
162
|
+
#
|
|
163
|
+
# This method does not propagate backwards into a CancellationToken that may
|
|
164
|
+
# have been used to create the Task. Tokens can be shared across operations;
|
|
165
|
+
# operation-wide cancellation is owned by the creating API.
|
|
166
|
+
# @api private
|
|
125
167
|
def cancel!(error = Phronomy::CancellationError.new("Task cancelled"))
|
|
126
168
|
changed = settle!(:cancelled, error: error)
|
|
127
169
|
if changed
|
|
@@ -131,6 +173,8 @@ module Phronomy
|
|
|
131
173
|
self
|
|
132
174
|
end
|
|
133
175
|
|
|
176
|
+
# Creates a derived Task by transforming this Task's successful result.
|
|
177
|
+
# @api public
|
|
134
178
|
def map(&block)
|
|
135
179
|
raise ArgumentError, "map requires a block" unless block
|
|
136
180
|
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# Carries all per-invocation context values through the call stack.
|
|
5
5
|
#
|
|
6
|
-
# +InvocationContext+ is a plain
|
|
7
|
-
#
|
|
8
|
-
# Pass it explicitly wherever context needs to cross a method boundary
|
|
9
|
-
# or be handed to a child {Task} / {TaskGroup}.
|
|
6
|
+
# +InvocationContext+ is a plain struct-like value carrier that replaces
|
|
7
|
+
# ad-hoc +Thread.current[...]+ propagation.
|
|
8
|
+
# Pass it explicitly wherever context needs to cross a method boundary.
|
|
10
9
|
#
|
|
11
10
|
# @example Build a context for a new agent invocation
|
|
12
11
|
# ctx = Phronomy::InvocationContext.new(
|
|
@@ -14,40 +13,12 @@ module Phronomy
|
|
|
14
13
|
# cancellation_token: Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
15
14
|
# )
|
|
16
15
|
# agent.invoke("Hello", invocation_context: ctx)
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
17
18
|
class InvocationContext
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# @return [String, nil] session identifier (e.g. Rails session id)
|
|
22
|
-
attr_reader :session_id
|
|
23
|
-
|
|
24
|
-
# @return [String, nil] end-user identifier for tracing / audit
|
|
25
|
-
attr_reader :user_id
|
|
26
|
-
|
|
27
|
-
# @return [CancellationToken, nil]
|
|
28
|
-
attr_reader :cancellation_token
|
|
29
|
-
|
|
30
|
-
# @return [Deadline, nil]
|
|
31
|
-
attr_reader :deadline
|
|
32
|
-
|
|
33
|
-
# @return [Object, nil] OpenTelemetry / tracing span
|
|
34
|
-
attr_reader :tracer_span
|
|
35
|
-
|
|
36
|
-
# @return [Integer, nil] max tokens the agent may consume this invocation
|
|
37
|
-
attr_reader :token_budget
|
|
38
|
-
|
|
39
|
-
# @return [#call, nil] invocation-specific Tool approval policy. The callable
|
|
40
|
-
# receives Phronomy::Agent::ApprovalEvaluationRequest.
|
|
41
|
-
attr_reader :approval_policy
|
|
42
|
-
|
|
43
|
-
# @return [Object, nil] redaction policy applied to tool args / results
|
|
44
|
-
attr_reader :redaction_policy
|
|
45
|
-
|
|
46
|
-
# @return [String, nil] unique identifier for this task in the trace tree
|
|
47
|
-
attr_reader :task_id
|
|
48
|
-
|
|
49
|
-
# @return [String, nil] task_id of the parent span / task
|
|
50
|
-
attr_reader :parent_task_id
|
|
19
|
+
attr_reader :thread_id, :session_id, :user_id, :cancellation_token,
|
|
20
|
+
:deadline, :tracer_span, :token_budget, :approval_policy,
|
|
21
|
+
:redaction_policy, :task_id, :parent_task_id
|
|
51
22
|
|
|
52
23
|
# @param thread_id [String, nil]
|
|
53
24
|
# @param session_id [String, nil]
|
|
@@ -56,11 +27,11 @@ module Phronomy
|
|
|
56
27
|
# @param deadline [Deadline, nil]
|
|
57
28
|
# @param tracer_span [Object, nil]
|
|
58
29
|
# @param token_budget [Integer, nil]
|
|
59
|
-
# @param approval_policy [#call, nil]
|
|
30
|
+
# @param approval_policy [#call, nil]
|
|
60
31
|
# @param redaction_policy [Object, nil]
|
|
61
32
|
# @param task_id [String, nil]
|
|
62
33
|
# @param parent_task_id [String, nil]
|
|
63
|
-
# @api
|
|
34
|
+
# @api public
|
|
64
35
|
def initialize(
|
|
65
36
|
thread_id: nil,
|
|
66
37
|
session_id: nil,
|
|
@@ -88,10 +59,6 @@ module Phronomy
|
|
|
88
59
|
end
|
|
89
60
|
|
|
90
61
|
# Returns a new +InvocationContext+ with the given attributes merged in.
|
|
91
|
-
# All other attributes are carried over unchanged.
|
|
92
|
-
#
|
|
93
|
-
# @param overrides [Hash] keyword arguments to override
|
|
94
|
-
# @return [InvocationContext]
|
|
95
62
|
# @api private
|
|
96
63
|
def merge(**overrides)
|
|
97
64
|
InvocationContext.new(
|
|
@@ -110,21 +77,13 @@ module Phronomy
|
|
|
110
77
|
end
|
|
111
78
|
|
|
112
79
|
# Convenience: returns the cancellation token or a new never-cancelled token.
|
|
113
|
-
# @return [CancellationToken]
|
|
114
80
|
# @api private
|
|
115
81
|
def effective_cancellation_token
|
|
116
82
|
@cancellation_token || Phronomy::Concurrency::CancellationToken.new
|
|
117
83
|
end
|
|
118
84
|
|
|
119
85
|
# Returns the cancellation token to use for an invocation, taking both the
|
|
120
|
-
# explicit
|
|
121
|
-
#
|
|
122
|
-
# - When +cancellation_token+ is set, it is returned unchanged.
|
|
123
|
-
# - When only +deadline+ is set, a new {CancellationToken} is created and
|
|
124
|
-
# the deadline is attached to it via {Deadline#attach_to}.
|
|
125
|
-
# - When neither is set, returns +nil+.
|
|
126
|
-
#
|
|
127
|
-
# @return [CancellationToken, nil]
|
|
86
|
+
# explicit cancellation_token and deadline into account.
|
|
128
87
|
# @api private
|
|
129
88
|
def effective_timeout_token
|
|
130
89
|
return @cancellation_token if @cancellation_token
|
|
@@ -2,56 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module LLMAdapter
|
|
5
|
-
#
|
|
5
|
+
# Beta extension SPI for LLM call adapters.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# External adapters implement {#complete} and {#stream}. The adapter or the
|
|
8
8
|
# underlying provider client owns transport timeout, retry, backoff, and
|
|
9
|
-
# rate-limit behavior. Phronomy
|
|
10
|
-
#
|
|
9
|
+
# rate-limit behavior. Phronomy owns cooperative cancellation and isolates
|
|
10
|
+
# synchronous provider calls in {OffloadPool}.
|
|
11
11
|
#
|
|
12
|
-
# The
|
|
13
|
-
#
|
|
12
|
+
# The Agent pipeline calls the framework-owned {#complete_async} and
|
|
13
|
+
# {#stream_async} wrappers. Adapter implementers do not need to depend on
|
|
14
|
+
# EventLoop, FSMSession, OffloadPool, AgentInvocation, or ExecutionCoordinator.
|
|
15
|
+
#
|
|
16
|
+
# The current input to this SPI is the configured/materialized chat runtime
|
|
17
|
+
# object. Formalizing this SPI therefore does not imply a provider-neutral
|
|
18
|
+
# replacement for RubyLLMMaterializer.
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
14
21
|
class Base
|
|
15
22
|
# Performs a blocking (non-streaming) LLM completion.
|
|
16
|
-
# Implementors must call +chat.ask(message)+ (or equivalent) and
|
|
17
|
-
# return the response object.
|
|
18
23
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
24
|
+
# Implementors call the configured chat/runtime client and return its
|
|
25
|
+
# response object. Transport/retry policy remains adapter-owned.
|
|
26
|
+
#
|
|
27
|
+
# @param chat [Object] the configured/materialized chat runtime object
|
|
28
|
+
# @param message [String, nil] user message, or nil to continue without adding a new user turn
|
|
21
29
|
# @param config [Hash] invocation config (e.g. +:cancellation_token+)
|
|
22
30
|
# @return [Object] LLM response object
|
|
23
31
|
# @raise [NotImplementedError]
|
|
24
|
-
# @api
|
|
32
|
+
# @api public
|
|
25
33
|
def complete(chat, message, config: {})
|
|
26
34
|
raise NotImplementedError, "#{self.class}#complete is not implemented"
|
|
27
35
|
end
|
|
28
36
|
|
|
29
37
|
# Performs a blocking streaming LLM completion.
|
|
30
|
-
# Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
|
|
31
|
-
# (or equivalent) and return the response object.
|
|
32
38
|
#
|
|
33
|
-
# @param chat [Object] the configured chat
|
|
34
|
-
# @param message [String]
|
|
39
|
+
# @param chat [Object] the configured/materialized chat runtime object
|
|
40
|
+
# @param message [String, nil] user message, or nil to continue without adding a new user turn
|
|
35
41
|
# @param config [Hash] invocation config
|
|
36
42
|
# @yield [chunk] streaming chunk from the LLM
|
|
37
43
|
# @return [Object] LLM response object
|
|
38
44
|
# @raise [NotImplementedError]
|
|
39
|
-
# @api
|
|
45
|
+
# @api public
|
|
40
46
|
def stream(chat, message, config: {}, &block)
|
|
41
47
|
raise NotImplementedError, "#{self.class}#stream is not implemented"
|
|
42
48
|
end
|
|
43
49
|
|
|
44
|
-
# Submits a non-streaming LLM call to {OffloadPool}
|
|
45
|
-
# an {OffloadPool::PendingOperation}.
|
|
50
|
+
# Submits a non-streaming LLM call to {OffloadPool}.
|
|
46
51
|
#
|
|
47
52
|
# Transport timeout and retry remain the responsibility of the adapter or
|
|
48
53
|
# provider client; Phronomy does not attach an additional operation timeout.
|
|
49
54
|
#
|
|
50
|
-
# @
|
|
51
|
-
# @param message [String] user message
|
|
52
|
-
# @param config [Hash] invocation config
|
|
53
|
-
# @param pool [OffloadPool] pool to submit to
|
|
54
|
-
# @return [OffloadPool::PendingOperation]
|
|
55
|
+
# @return [Phronomy::Task] caller-facing completion handle
|
|
55
56
|
# @api private
|
|
56
57
|
def complete_async(chat, message, config: {}, pool: default_pool)
|
|
57
58
|
token = config[:cancellation_token]
|
|
@@ -60,18 +61,14 @@ module Phronomy
|
|
|
60
61
|
end
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# Submits a streaming LLM call to {OffloadPool}
|
|
64
|
-
# an {OffloadPool::PendingOperation}.
|
|
65
|
-
#
|
|
66
|
-
# The block is invoked on an OffloadPool worker thread. Agent code must
|
|
67
|
-
# pass only a lightweight internal sink that posts a value to EventLoop;
|
|
68
|
-
# Application callbacks must never be passed directly to this method.
|
|
64
|
+
# Submits a streaming LLM call to {OffloadPool}.
|
|
69
65
|
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
66
|
+
# The block is invoked on an OffloadPool worker thread. Agent code must pass
|
|
67
|
+
# only a lightweight internal sink that posts a value to EventLoop;
|
|
68
|
+
# application callbacks must never be passed directly to this method.
|
|
72
69
|
#
|
|
73
70
|
# @yield [chunk] streaming chunk on the worker thread
|
|
74
|
-
# @return [
|
|
71
|
+
# @return [Phronomy::Task] caller-facing completion handle
|
|
75
72
|
# @api private
|
|
76
73
|
def stream_async(chat, message, config: {}, pool: default_pool, &block)
|
|
77
74
|
raise ArgumentError, "stream_async requires a block" unless block
|
|
@@ -2,42 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module LLMAdapter
|
|
5
|
-
#
|
|
5
|
+
# Default LLMAdapter SPI implementation backed by RubyLLM.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# The synchronous +chat.ask+ / +chat.complete+ calls are invoked through the
|
|
8
|
+
# framework-owned async bridge in {LLMAdapter::Base}, so adapter consumers do
|
|
9
|
+
# not need to manage OffloadPool themselves.
|
|
10
10
|
#
|
|
11
11
|
# @example Explicitly configuring this adapter
|
|
12
12
|
# Phronomy.configure do |c|
|
|
13
13
|
# c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
14
14
|
# end
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
15
17
|
class RubyLLM < Base
|
|
16
18
|
# Delegates to +chat.ask(message)+ or +chat.complete+ when message is nil.
|
|
17
19
|
#
|
|
18
20
|
# Passing +nil+ for +message+ is used by the ReAct loop for continuation
|
|
19
21
|
# turns where the user message has already been added to the chat history
|
|
20
|
-
# (
|
|
21
|
-
# user turn.
|
|
22
|
+
# (for example after a Tool result).
|
|
22
23
|
#
|
|
23
24
|
# @param chat [Object] RubyLLM chat session
|
|
24
25
|
# @param message [String, nil] user message, or nil to continue the chat
|
|
25
|
-
# @param config [Hash] invocation config (not used directly
|
|
26
|
+
# @param config [Hash] invocation config (not used directly here)
|
|
26
27
|
# @return [Object] RubyLLM response
|
|
27
|
-
# @api
|
|
28
|
+
# @api public
|
|
28
29
|
def complete(chat, message, config: {})
|
|
29
30
|
message ? chat.ask(message) : chat.complete
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# Delegates to +chat.ask(message) { |chunk|
|
|
33
|
-
#
|
|
33
|
+
# Delegates to +chat.ask(message) { |chunk| ... }+ or +chat.complete(&block)+
|
|
34
|
+
# when message is nil.
|
|
34
35
|
#
|
|
35
36
|
# @param chat [Object] RubyLLM chat session
|
|
36
37
|
# @param message [String, nil] user message, or nil to continue the chat
|
|
37
38
|
# @param config [Hash] invocation config
|
|
38
|
-
# @yield [chunk] streaming chunk forwarded from
|
|
39
|
+
# @yield [chunk] streaming chunk forwarded from RubyLLM
|
|
39
40
|
# @return [Object] RubyLLM response
|
|
40
|
-
# @api
|
|
41
|
+
# @api public
|
|
41
42
|
def stream(chat, message, config: {}, &block)
|
|
42
43
|
message ? chat.ask(message, &block) : chat.complete(&block)
|
|
43
44
|
end
|
data/lib/phronomy/llm_adapter.rb
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Namespace for LLM
|
|
4
|
+
# Namespace for LLM call adapters.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# The public extension boundary is {LLMAdapter::Base#complete} and
|
|
7
|
+
# {LLMAdapter::Base#stream}. Those methods receive Phronomy's currently
|
|
8
|
+
# materialized chat/runtime object and perform the provider call. Phronomy owns
|
|
9
|
+
# the async/offload bridge around that synchronous contract.
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# This SPI does not by itself make the complete input-materialization pipeline
|
|
12
|
+
# provider-neutral: the current Agent pipeline still materializes the canonical
|
|
13
|
+
# LLM input through RubyLLM-specific runtime objects before invoking the adapter.
|
|
13
14
|
#
|
|
14
15
|
# @example Configuring a custom adapter
|
|
15
16
|
# Phronomy.configure do |c|
|
|
16
17
|
# c.llm_adapter = MyCustomAdapter.new
|
|
17
18
|
# end
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
18
21
|
module LLMAdapter
|
|
19
22
|
end
|
|
20
23
|
end
|
|
@@ -14,7 +14,11 @@ module Phronomy
|
|
|
14
14
|
parse(input.is_a?(String) ? input : input.to_s)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# Extension point implemented by custom parsers.
|
|
18
|
+
#
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @return [Object]
|
|
21
|
+
# @api public
|
|
18
22
|
def parse(text)
|
|
19
23
|
raise NotImplementedError, "#{self.class}#parse is not implemented"
|
|
20
24
|
end
|
data/lib/phronomy/persistence.rb
CHANGED
|
@@ -5,9 +5,53 @@ module Phronomy
|
|
|
5
5
|
class ConflictError < Phronomy::Error; end
|
|
6
6
|
class NotFoundError < Phronomy::Error; end
|
|
7
7
|
class UnsupportedBackendError < Phronomy::Error; end
|
|
8
|
+
class SerializationError < Phronomy::Error; end
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
REQUIRED_CAPABILITIES = {
|
|
11
|
+
atomic_all: true,
|
|
12
|
+
atomic_admission: true,
|
|
13
|
+
optimistic_revision: true
|
|
14
|
+
}.freeze
|
|
10
15
|
|
|
16
|
+
# Durable repository accessors supplied by a Persistence backend.
|
|
17
|
+
#
|
|
18
|
+
# The repository objects are part of the Backend SPI. They may be private
|
|
19
|
+
# implementation classes owned by the backend; they do not need to inherit
|
|
20
|
+
# from Phronomy repository base classes.
|
|
21
|
+
#
|
|
22
|
+
# @return [Object] content-addressed immutable content repository
|
|
23
|
+
# @api public
|
|
24
|
+
attr_reader :contents
|
|
25
|
+
|
|
26
|
+
# @return [Object] AgentRoot repository
|
|
27
|
+
# @api public
|
|
28
|
+
attr_reader :agents
|
|
29
|
+
|
|
30
|
+
# @return [Object] append-only Agent Journal repository
|
|
31
|
+
# @api public
|
|
32
|
+
attr_reader :journals
|
|
33
|
+
|
|
34
|
+
# @return [Object] AgentExecution repository
|
|
35
|
+
# @api public
|
|
36
|
+
attr_reader :executions
|
|
37
|
+
|
|
38
|
+
# @return [Object] durable Workflow snapshot repository
|
|
39
|
+
# @api public
|
|
40
|
+
attr_reader :workflow_states
|
|
41
|
+
|
|
42
|
+
# Initializes a Persistence backend with its durable repositories.
|
|
43
|
+
#
|
|
44
|
+
# Subclasses normally construct backend-specific repository objects and then
|
|
45
|
+
# call +super+. The backend must advertise every capability in
|
|
46
|
+
# {REQUIRED_CAPABILITIES}; construction fails fast otherwise.
|
|
47
|
+
#
|
|
48
|
+
# @param contents [Object]
|
|
49
|
+
# @param agents [Object]
|
|
50
|
+
# @param journals [Object]
|
|
51
|
+
# @param executions [Object]
|
|
52
|
+
# @param workflow_states [Object]
|
|
53
|
+
# @raise [UnsupportedBackendError] when a required capability is missing
|
|
54
|
+
# @api public
|
|
11
55
|
def initialize(contents:, agents:, journals:, executions:, workflow_states:)
|
|
12
56
|
@contents = contents
|
|
13
57
|
@agents = agents
|
|
@@ -17,18 +61,67 @@ module Phronomy
|
|
|
17
61
|
validate_capabilities!
|
|
18
62
|
end
|
|
19
63
|
|
|
64
|
+
# Declares storage semantics provided by this backend.
|
|
65
|
+
#
|
|
66
|
+
# Required meanings:
|
|
67
|
+
# - +atomic_all+: all durable repositories can participate in one atomic
|
|
68
|
+
# transaction domain.
|
|
69
|
+
# - +atomic_admission+: Agent execution admission is atomic; at most one
|
|
70
|
+
# active/suspended execution may be admitted for one Agent. This does not
|
|
71
|
+
# mean cross-process Workflow admission or distributed locking.
|
|
72
|
+
# - +optimistic_revision+: Agent, Execution, Workflow revision checks and
|
|
73
|
+
# Journal position checks provide compare-and-swap conflict detection.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{Symbol => Boolean}]
|
|
76
|
+
# @api public
|
|
20
77
|
def capabilities
|
|
21
|
-
{
|
|
78
|
+
{
|
|
79
|
+
atomic_all: false,
|
|
80
|
+
atomic_admission: false,
|
|
81
|
+
optimistic_revision: false
|
|
82
|
+
}.freeze
|
|
22
83
|
end
|
|
23
84
|
|
|
85
|
+
# Executes one atomic durable transaction.
|
|
86
|
+
#
|
|
87
|
+
# The object yielded to the block is a transaction-scoped Persistence view.
|
|
88
|
+
# It must respond to +contents+, +agents+, +journals+, +executions+,
|
|
89
|
+
# +workflow_states+, and +assert_agent_watermark!+. It may be +self+, but
|
|
90
|
+
# backends are free to yield a separate transaction view backed by a checked
|
|
91
|
+
# out connection/session.
|
|
92
|
+
#
|
|
93
|
+
# If the block raises, mutations made through the transaction view must not
|
|
94
|
+
# be committed. Storage failures whose commit outcome is fundamentally
|
|
95
|
+
# unknown remain backend/database failures; Phronomy does not claim
|
|
96
|
+
# exactly-once semantics for such failures.
|
|
97
|
+
#
|
|
98
|
+
# @yieldparam transaction_view [Object]
|
|
99
|
+
# @return [Object] the block result
|
|
100
|
+
# @raise [UnsupportedBackendError] when atomic transactions are unavailable
|
|
101
|
+
# @api public
|
|
24
102
|
def transaction
|
|
25
103
|
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
26
104
|
end
|
|
27
105
|
|
|
28
106
|
# Verifies that a live Agent still owns the durable base it hydrated.
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
107
|
+
#
|
|
108
|
+
# This is a Backend SPI operation invoked by Phronomy at durable barriers.
|
|
109
|
+
# Ordinary application code should not call it directly. The backend must
|
|
110
|
+
# compare the stored Agent revision and current Journal position against the
|
|
111
|
+
# supplied watermark in the same storage consistency view used by subsequent
|
|
112
|
+
# writes in the surrounding transaction.
|
|
113
|
+
#
|
|
114
|
+
# The method is a precondition check only. It must not reload or return
|
|
115
|
+
# replacement mutable Agent state; the live Agent remains the logical owner.
|
|
116
|
+
#
|
|
117
|
+
# @param agent_id [String]
|
|
118
|
+
# @param agent_revision [Integer]
|
|
119
|
+
# @param journal_position [Integer]
|
|
120
|
+
# @return [true]
|
|
121
|
+
# @raise [NotFoundError] when the Agent does not exist
|
|
122
|
+
# @raise [ConflictError] when either durable watermark component differs
|
|
123
|
+
# @raise [UnsupportedBackendError] when the backend does not implement the check
|
|
124
|
+
# @api public
|
|
32
125
|
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
33
126
|
raise UnsupportedBackendError,
|
|
34
127
|
"#{self.class} does not provide Agent durable-watermark checks"
|
|
@@ -37,8 +130,9 @@ module Phronomy
|
|
|
37
130
|
private
|
|
38
131
|
|
|
39
132
|
def validate_capabilities!
|
|
40
|
-
|
|
41
|
-
|
|
133
|
+
missing = REQUIRED_CAPABILITIES.reject do |key, value|
|
|
134
|
+
capabilities[key] == value
|
|
135
|
+
end
|
|
42
136
|
return if missing.empty?
|
|
43
137
|
|
|
44
138
|
raise UnsupportedBackendError,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_examples "a persistence content store" do
|
|
4
|
+
let(:content_store) { persistence.contents }
|
|
5
|
+
|
|
6
|
+
it "returns the same content_id for the same bytes" do
|
|
7
|
+
first = content_store.put("same".b, canonicalization_version: 1)
|
|
8
|
+
second = content_store.put("same".b, canonicalization_version: 1)
|
|
9
|
+
|
|
10
|
+
expect(second).to eq(first)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "round-trips binary bytes" do
|
|
14
|
+
bytes = "\x00\xFFpayload".b
|
|
15
|
+
content_id = content_store.put(bytes, canonicalization_version: 1)
|
|
16
|
+
|
|
17
|
+
expect(content_store.fetch(content_id)).to eq(bytes)
|
|
18
|
+
expect(content_store.exist?(content_id)).to be(true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "raises NotFoundError for a missing content_id" do
|
|
22
|
+
expect do
|
|
23
|
+
content_store.fetch("sha256:#{"0" * 64}")
|
|
24
|
+
end.to raise_error(Phronomy::Persistence::NotFoundError)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "isolates durable bytes from mutation of fetched values" do
|
|
28
|
+
content_id = content_store.put("immutable".b, canonicalization_version: 1)
|
|
29
|
+
fetched = content_store.fetch(content_id)
|
|
30
|
+
fetched << "-caller-change"
|
|
31
|
+
|
|
32
|
+
expect(content_store.fetch(content_id)).to eq("immutable".b)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "supports UTF-8 text helpers" do
|
|
36
|
+
content_id = content_store.put_text("Grüße")
|
|
37
|
+
|
|
38
|
+
expect(content_store.fetch_text(content_id)).to eq("Grüße")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "supports canonical JSON helpers" do
|
|
42
|
+
value = {
|
|
43
|
+
"z" => [1, true, nil],
|
|
44
|
+
"a" => {"message" => "hello"}
|
|
45
|
+
}
|
|
46
|
+
content_id = content_store.put_json(value)
|
|
47
|
+
|
|
48
|
+
expect(content_store.fetch_json(content_id)).to eq(value)
|
|
49
|
+
end
|
|
50
|
+
end
|