phronomy 0.7.0 → 0.7.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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -5,7 +5,6 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "shellwords"
|
|
8
|
-
require "timeout"
|
|
9
8
|
require "uri"
|
|
10
9
|
|
|
11
10
|
module Phronomy
|
|
@@ -67,7 +66,7 @@ module Phronomy
|
|
|
67
66
|
|
|
68
67
|
def build_tool_class(tool_name, server_uri, tool_def)
|
|
69
68
|
klass = Class.new(McpTool)
|
|
70
|
-
klass.
|
|
69
|
+
klass.tool_name(tool_name)
|
|
71
70
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
72
71
|
|
|
73
72
|
# Register description and params from the MCP tool definition.
|
|
@@ -118,7 +117,9 @@ module Phronomy
|
|
|
118
117
|
# {HttpTransport}. Defaults to 30 seconds.
|
|
119
118
|
# @param env [Hash, nil] environment variable overrides for the subprocess.
|
|
120
119
|
# When provided, only these variables are added/overridden; the parent environment
|
|
121
|
-
# is still inherited
|
|
120
|
+
# is still inherited. Use +nil+ as a value to unset a variable in the child process
|
|
121
|
+
# (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
|
|
122
|
+
# an empty string — it does NOT unset it.
|
|
122
123
|
# @param cwd [String, nil] working directory for the subprocess.
|
|
123
124
|
# Defaults to the current process's working directory.
|
|
124
125
|
# @param startup_timeout [Numeric, nil] seconds to wait for the server to
|
|
@@ -138,6 +139,7 @@ module Phronomy
|
|
|
138
139
|
@stderr = nil
|
|
139
140
|
@wait_thr = nil
|
|
140
141
|
@stderr_thread = nil
|
|
142
|
+
@stderr_op = nil
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
# Shut down the child process and close its IO streams.
|
|
@@ -149,10 +151,17 @@ module Phronomy
|
|
|
149
151
|
@stdout = nil
|
|
150
152
|
@stderr = nil
|
|
151
153
|
stderr_thread = @stderr_thread
|
|
154
|
+
stderr_op = @stderr_op
|
|
152
155
|
wait_thr = @wait_thr
|
|
153
156
|
@stderr_thread = nil
|
|
157
|
+
@stderr_op = nil
|
|
154
158
|
@wait_thr = nil
|
|
155
159
|
stderr_thread&.join(1)
|
|
160
|
+
begin
|
|
161
|
+
stderr_op&.await(timeout: 1.0)
|
|
162
|
+
rescue
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
156
165
|
wait_thr&.join(5)
|
|
157
166
|
end
|
|
158
167
|
|
|
@@ -209,31 +218,53 @@ module Phronomy
|
|
|
209
218
|
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
210
219
|
# and deadlocking the child process. Errors inside the drain thread are
|
|
211
220
|
# silently ignored since stderr content is diagnostics-only.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
#
|
|
222
|
+
# Prefer BlockingAdapterPool when a Runtime is configured so that this
|
|
223
|
+
# file eventually needs no direct Thread.new (Issue #360). Fall back to
|
|
224
|
+
# Thread.new when no pool is available (no EventLoop / bare invocation).
|
|
225
|
+
pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
|
|
226
|
+
if pool
|
|
227
|
+
@stderr_op = pool.submit {
|
|
228
|
+
begin
|
|
229
|
+
@stderr.read
|
|
230
|
+
rescue
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
}
|
|
234
|
+
@stderr_thread = nil
|
|
235
|
+
else
|
|
236
|
+
@stderr_thread = Thread.new {
|
|
237
|
+
begin
|
|
238
|
+
@stderr.read
|
|
239
|
+
rescue
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
}
|
|
243
|
+
@stderr_op = nil
|
|
216
244
|
end
|
|
217
245
|
|
|
218
246
|
if @startup_timeout
|
|
219
|
-
|
|
247
|
+
unless IO.select([@stdout], nil, nil, @startup_timeout)
|
|
248
|
+
close
|
|
249
|
+
raise Phronomy::ToolError,
|
|
250
|
+
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
251
|
+
end
|
|
252
|
+
line = @stdout.gets
|
|
253
|
+
@stdout.ungetbyte(line) if line
|
|
220
254
|
end
|
|
221
|
-
rescue Timeout::Error
|
|
222
|
-
close
|
|
223
|
-
raise Phronomy::ToolError,
|
|
224
|
-
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
225
255
|
end
|
|
226
256
|
|
|
227
257
|
def rpc_call(method, params)
|
|
228
258
|
ensure_started!
|
|
229
259
|
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
230
260
|
@stdin.puts(payload)
|
|
231
|
-
|
|
261
|
+
unless IO.select([@stdout], nil, nil, @read_timeout)
|
|
262
|
+
raise Phronomy::ToolError,
|
|
263
|
+
"MCP stdio server did not respond within #{@read_timeout} seconds"
|
|
264
|
+
end
|
|
265
|
+
raw = @stdout.gets
|
|
232
266
|
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
233
267
|
JSON.parse(raw)
|
|
234
|
-
rescue Timeout::Error
|
|
235
|
-
raise Phronomy::ToolError,
|
|
236
|
-
"MCP stdio server did not respond within #{@read_timeout} seconds"
|
|
237
268
|
end
|
|
238
269
|
|
|
239
270
|
def parse_schema_params(properties, required_names: [])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Tool
|
|
5
|
+
# Evaluates whether a tool with a given scope may execute.
|
|
6
|
+
#
|
|
7
|
+
# A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
|
|
8
|
+
# returns one of:
|
|
9
|
+
# +:allow+ — proceed immediately without an approval gate.
|
|
10
|
+
# +:reject+ — block execution; the tool returns a denial message.
|
|
11
|
+
# +:approve+ — delegate to the agent's approval handler (if registered);
|
|
12
|
+
# when no handler is registered the call is rejected.
|
|
13
|
+
#
|
|
14
|
+
# The {Default} instance is used automatically when no custom policy is
|
|
15
|
+
# configured on an agent.
|
|
16
|
+
#
|
|
17
|
+
# @example Custom policy that allows everything
|
|
18
|
+
# agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
|
|
19
|
+
#
|
|
20
|
+
# @example Strict policy that rejects all write scopes
|
|
21
|
+
# agent.scope_policy = ->(_tc, scope, _agent) {
|
|
22
|
+
# scope == :write ? :reject : :allow
|
|
23
|
+
# }
|
|
24
|
+
class ScopePolicy
|
|
25
|
+
# Scopes that must go through an approval gate before execution.
|
|
26
|
+
APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
|
|
27
|
+
|
|
28
|
+
# Scopes that are always permitted without approval.
|
|
29
|
+
ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
|
|
30
|
+
|
|
31
|
+
# Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
|
|
32
|
+
# scopes, and +:allow+ for anything else (including +nil+).
|
|
33
|
+
#
|
|
34
|
+
# @param _tool_class [Class]
|
|
35
|
+
# @param scope [Symbol, nil]
|
|
36
|
+
# @param _agent [Object]
|
|
37
|
+
# @return [:allow, :approve, :reject]
|
|
38
|
+
# @api private
|
|
39
|
+
def call(_tool_class, scope, _agent)
|
|
40
|
+
return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
|
|
41
|
+
return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
|
|
42
|
+
|
|
43
|
+
:allow
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shared singleton used when no custom policy is configured.
|
|
47
|
+
DEFAULT = new.freeze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Centralises tool execution routing based on {Tool::Base.execution_mode}.
|
|
5
|
+
#
|
|
6
|
+
# This is the single place in the framework that decides *how* a tool call is
|
|
7
|
+
# dispatched:
|
|
8
|
+
#
|
|
9
|
+
# - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
|
|
10
|
+
# scheduler. Under the +:fiber+ backend this avoids an
|
|
11
|
+
# extra OS thread; under the +:thread+ backend it is
|
|
12
|
+
# backed by +ThreadScheduler+ (one thread per task).
|
|
13
|
+
# - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
|
|
14
|
+
# provides a pool; falls back to +Runtime#spawn+ otherwise.
|
|
15
|
+
# - +:cpu_bound+ — emits a deprecation-style warning then falls back to
|
|
16
|
+
# +:blocking_io+ routing (no process pool available yet).
|
|
17
|
+
# - +:external_process+ — falls back to +:blocking_io+ routing (no process
|
|
18
|
+
# manager available yet).
|
|
19
|
+
#
|
|
20
|
+
# All paths return an object that responds to +#await+ (+Phronomy::Task+ or
|
|
21
|
+
# +BlockingAdapterPool::PendingOperation+), so callers can collect results
|
|
22
|
+
# uniformly.
|
|
23
|
+
#
|
|
24
|
+
# @note Non-goals
|
|
25
|
+
# ToolExecutor deliberately does NOT provide:
|
|
26
|
+
# - A CPU-bound process pool. CPU-intensive tool work must be handled at the
|
|
27
|
+
# application layer (e.g., fork, Sidekiq, separate OS processes). The
|
|
28
|
+
# framework will not add a +ProcessPoolExecutor+ equivalent.
|
|
29
|
+
# - An external process manager. Spawning or supervising subprocesses is
|
|
30
|
+
# out of scope for this module.
|
|
31
|
+
# - Additional core execution routes beyond scheduler-backed cooperative
|
|
32
|
+
# execution and BlockingAdapterPool-backed blocking I/O isolation.
|
|
33
|
+
# The +:cpu_bound+ and +:external_process+ modes are accepted for
|
|
34
|
+
# compatibility but both fall back to +:blocking_io+ routing with a
|
|
35
|
+
# one-time warning. If a genuinely new core execution route is needed,
|
|
36
|
+
# a new ADR is required.
|
|
37
|
+
# These non-goals follow from the cooperative-first, non-preemptive
|
|
38
|
+
# concurrency model (ADR-010): framework components must not assume the
|
|
39
|
+
# caller's concurrency model, and CPU/process management belongs to the
|
|
40
|
+
# application layer.
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
module ToolExecutor
|
|
44
|
+
# Tracks tool classes that have already emitted an execution_mode warning so
|
|
45
|
+
# that the same warning is only logged once per process lifetime.
|
|
46
|
+
WARNED_MODES = Set.new
|
|
47
|
+
WARNED_MODES_MUTEX = Mutex.new
|
|
48
|
+
private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
|
|
49
|
+
|
|
50
|
+
# Dispatches a single tool call asynchronously according to its
|
|
51
|
+
# +execution_mode+ and returns an awaitable.
|
|
52
|
+
#
|
|
53
|
+
# @param tool [Phronomy::Tool::Base] the tool instance to invoke
|
|
54
|
+
# @param args [Hash] argument hash to pass to {Tool::Base#call}
|
|
55
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
56
|
+
# @param runtime [Phronomy::Runtime] runtime to use for spawning
|
|
57
|
+
# (defaults to {Runtime.instance}; injectable for tests)
|
|
58
|
+
# @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
|
|
59
|
+
# @api private
|
|
60
|
+
def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
|
|
61
|
+
ct = cancellation_token
|
|
62
|
+
mode = tool.class.execution_mode
|
|
63
|
+
|
|
64
|
+
# Warn and normalise unsupported modes to :blocking_io.
|
|
65
|
+
# Each (tool class, mode) pair emits the warning at most once per process
|
|
66
|
+
# lifetime to avoid log flooding in high-throughput scenarios.
|
|
67
|
+
if mode == :cpu_bound || mode == :external_process
|
|
68
|
+
warn_key = [tool.class.name, mode]
|
|
69
|
+
newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
|
|
70
|
+
if newly_warned
|
|
71
|
+
msg = if mode == :cpu_bound
|
|
72
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
|
|
73
|
+
"which has no dedicated executor. " \
|
|
74
|
+
"Falling back to blocking_io (BlockingAdapterPool). " \
|
|
75
|
+
"Use :blocking_io explicitly to suppress this warning."
|
|
76
|
+
else
|
|
77
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
|
|
78
|
+
"which has no dedicated process manager. " \
|
|
79
|
+
"Falling back to blocking_io (BlockingAdapterPool)."
|
|
80
|
+
end
|
|
81
|
+
if Phronomy.configuration.logger
|
|
82
|
+
Phronomy.configuration.logger.warn(msg)
|
|
83
|
+
else
|
|
84
|
+
warn msg
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
mode = :blocking_io
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
pool = begin
|
|
91
|
+
runtime&.blocking_io
|
|
92
|
+
rescue
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if mode == :cooperative || pool.nil?
|
|
97
|
+
runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
|
|
98
|
+
tool.call(args, cancellation_token: ct)
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
# Submit directly to pool — no wrapping Task thread required.
|
|
102
|
+
pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -52,6 +52,40 @@ module Phronomy
|
|
|
52
52
|
end
|
|
53
53
|
span.finish
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
# Overrides Base#trace to use OTel +in_span+, which pushes the span onto
|
|
57
|
+
# the OTel Context stack while the block runs. Nested +trace+ calls
|
|
58
|
+
# automatically inherit the current span as their parent, establishing the
|
|
59
|
+
# correct parent/child hierarchy in the trace tree.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] span name
|
|
62
|
+
# @param input [Object, nil] input to record as a span attribute
|
|
63
|
+
# @param meta [Hash] additional metadata (e.g. task_id, user_id)
|
|
64
|
+
# @yield [span] the active OTel span
|
|
65
|
+
# @return [Object] the block's return value
|
|
66
|
+
# @api private
|
|
67
|
+
def trace(name, input: nil, **meta)
|
|
68
|
+
attrs = {}
|
|
69
|
+
attrs["phronomy.input"] = input.to_s if input
|
|
70
|
+
meta.each { |k, v| attrs["phronomy.#{k}"] = v.to_s unless v.nil? }
|
|
71
|
+
|
|
72
|
+
result = nil
|
|
73
|
+
@otel_tracer.in_span(name, attributes: attrs) do |span|
|
|
74
|
+
result, usage = yield span
|
|
75
|
+
span.set_attribute("phronomy.output", result.to_s) if result
|
|
76
|
+
if usage
|
|
77
|
+
span.set_attribute("llm.usage.input_tokens", usage.input)
|
|
78
|
+
span.set_attribute("llm.usage.output_tokens", usage.output)
|
|
79
|
+
total = (usage.input || 0) + (usage.output || 0)
|
|
80
|
+
span.set_attribute("llm.usage.total_tokens", total)
|
|
81
|
+
end
|
|
82
|
+
rescue => e
|
|
83
|
+
span.record_exception(e)
|
|
84
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
55
89
|
end
|
|
56
90
|
end
|
|
57
91
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module VectorStore
|
|
5
|
+
# Mixin that defines the async interface for VectorStore backends.
|
|
6
|
+
#
|
|
7
|
+
# Mixing this module into a VectorStore class provides three choices:
|
|
8
|
+
#
|
|
9
|
+
# 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
|
|
10
|
+
# that route through {BlockingAdapterPool} (the previous behaviour).
|
|
11
|
+
#
|
|
12
|
+
# 2. **Override selectively** — override only the async methods where the
|
|
13
|
+
# backend has a native async driver, while the remaining methods fall back
|
|
14
|
+
# to the pool.
|
|
15
|
+
#
|
|
16
|
+
# 3. **Implement all natively** — override all async methods to avoid pool
|
|
17
|
+
# allocation entirely.
|
|
18
|
+
#
|
|
19
|
+
# @example Native async search (no pool worker thread allocated)
|
|
20
|
+
# class MyFastStore < Phronomy::VectorStore::Base
|
|
21
|
+
# include Phronomy::VectorStore::AsyncBackend
|
|
22
|
+
#
|
|
23
|
+
# def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
|
|
24
|
+
# # Returns a PendingOperation backed by a native async driver.
|
|
25
|
+
# native_async_search(query_embedding, k)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
module AsyncBackend
|
|
31
|
+
# Async variant of {VectorStore::Base#add}.
|
|
32
|
+
#
|
|
33
|
+
# Submits the add call to {BlockingAdapterPool} by default.
|
|
34
|
+
# Override to use a native async driver.
|
|
35
|
+
#
|
|
36
|
+
# @param id [String]
|
|
37
|
+
# @param embedding [Array<Float>]
|
|
38
|
+
# @param metadata [Hash]
|
|
39
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
40
|
+
# @param timeout [Numeric, nil]
|
|
41
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
42
|
+
# @api public
|
|
43
|
+
def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
|
|
44
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
45
|
+
timeout: timeout,
|
|
46
|
+
cancellation_token: cancellation_token
|
|
47
|
+
) do
|
|
48
|
+
add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Async variant of {VectorStore::Base#search}.
|
|
53
|
+
#
|
|
54
|
+
# Submits the search call to {BlockingAdapterPool} by default.
|
|
55
|
+
# Override to use a native async driver.
|
|
56
|
+
#
|
|
57
|
+
# @param query_embedding [Array<Float>]
|
|
58
|
+
# @param k [Integer]
|
|
59
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
60
|
+
# @param timeout [Numeric, nil]
|
|
61
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
62
|
+
# @api public
|
|
63
|
+
def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
|
|
64
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
65
|
+
timeout: timeout,
|
|
66
|
+
cancellation_token: cancellation_token
|
|
67
|
+
) do
|
|
68
|
+
search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Async variant of {VectorStore::Base#remove}.
|
|
73
|
+
#
|
|
74
|
+
# Submits the remove call to {BlockingAdapterPool} by default.
|
|
75
|
+
# Override to use a native async driver.
|
|
76
|
+
#
|
|
77
|
+
# @param id [String]
|
|
78
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
79
|
+
# @param timeout [Numeric, nil]
|
|
80
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
81
|
+
# @api public
|
|
82
|
+
def remove_async(id:, cancellation_token: nil, timeout: nil)
|
|
83
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
84
|
+
timeout: timeout,
|
|
85
|
+
cancellation_token: cancellation_token
|
|
86
|
+
) do
|
|
87
|
+
remove(id: id)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Async variant of {VectorStore::Base#clear}.
|
|
92
|
+
#
|
|
93
|
+
# Submits the clear call to {BlockingAdapterPool} by default.
|
|
94
|
+
# Override to use a native async driver.
|
|
95
|
+
#
|
|
96
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
97
|
+
# @param timeout [Numeric, nil]
|
|
98
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
99
|
+
# @api public
|
|
100
|
+
def clear_async(cancellation_token: nil, timeout: nil)
|
|
101
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
102
|
+
timeout: timeout,
|
|
103
|
+
cancellation_token: cancellation_token
|
|
104
|
+
) do
|
|
105
|
+
clear
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -6,7 +6,14 @@ module Phronomy
|
|
|
6
6
|
#
|
|
7
7
|
# Implementations manage a collection of (embedding, metadata) pairs and
|
|
8
8
|
# support similarity search.
|
|
9
|
+
#
|
|
10
|
+
# Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
|
|
11
|
+
# are provided by the {AsyncBackend} mixin which defaults to routing calls
|
|
12
|
+
# through {BlockingAdapterPool}. Backends with native async drivers may
|
|
13
|
+
# override individual async methods without touching the pool at all.
|
|
9
14
|
class Base
|
|
15
|
+
include AsyncBackend
|
|
16
|
+
|
|
10
17
|
# Add a document with its vector embedding.
|
|
11
18
|
#
|
|
12
19
|
# @param id [String] unique document identifier
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -81,12 +81,35 @@ module Phronomy
|
|
|
81
81
|
# Executes the workflow from the initial state.
|
|
82
82
|
# @param input [Hash] initial context field values
|
|
83
83
|
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
84
|
+
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
85
|
+
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
86
|
+
# derived from it (existing +config:+ keys take precedence). The object is also
|
|
87
|
+
# stored in +config[:invocation_context]+ for downstream tracing.
|
|
84
88
|
# @return [Object] final context
|
|
85
89
|
# @api public
|
|
86
|
-
def invoke(input, config: {})
|
|
90
|
+
def invoke(input, config: {}, invocation_context: nil)
|
|
91
|
+
if invocation_context
|
|
92
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
93
|
+
end
|
|
87
94
|
@runner.invoke(input, config: config)
|
|
88
95
|
end
|
|
89
96
|
|
|
97
|
+
# Invokes this workflow asynchronously and returns a {Phronomy::Task}.
|
|
98
|
+
#
|
|
99
|
+
# @param input [Hash]
|
|
100
|
+
# @param config [Hash]
|
|
101
|
+
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
102
|
+
# @return [Phronomy::Task]
|
|
103
|
+
# @api public
|
|
104
|
+
def invoke_async(input, config: {}, invocation_context: nil)
|
|
105
|
+
if invocation_context
|
|
106
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
107
|
+
end
|
|
108
|
+
Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
|
|
109
|
+
invoke(input, config: config)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
90
113
|
# Resumes a halted workflow. Generic resume that works for all halt types.
|
|
91
114
|
# @param state [Object] halted context
|
|
92
115
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
@@ -116,6 +139,23 @@ module Phronomy
|
|
|
116
139
|
@runner.stream(input, config: config, &block)
|
|
117
140
|
end
|
|
118
141
|
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Merges an {InvocationContext} into the config hash.
|
|
145
|
+
# Existing +config+ keys take precedence (backward-compat).
|
|
146
|
+
def _apply_invocation_context(config, ic)
|
|
147
|
+
effective = config.merge(invocation_context: ic)
|
|
148
|
+
effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
|
|
149
|
+
if effective[:cancellation_token].nil?
|
|
150
|
+
if (tok = ic.effective_timeout_token)
|
|
151
|
+
effective = effective.merge(cancellation_token: tok)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
effective
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
public
|
|
158
|
+
|
|
119
159
|
# ---------------------------------------------------------------------------
|
|
120
160
|
# Internal DSL builder
|
|
121
161
|
# ---------------------------------------------------------------------------
|
|
@@ -139,6 +179,8 @@ module Phronomy
|
|
|
139
179
|
@transitions = []
|
|
140
180
|
# Set of wait state names
|
|
141
181
|
@wait_state_names = []
|
|
182
|
+
# { state_name => Numeric } — per-state action timeout in seconds
|
|
183
|
+
@action_timeouts = {}
|
|
142
184
|
end
|
|
143
185
|
|
|
144
186
|
# Declares the initial (entry) state.
|
|
@@ -151,13 +193,17 @@ module Phronomy
|
|
|
151
193
|
# rubocop:enable Style/TrivialAccessors
|
|
152
194
|
|
|
153
195
|
# Declares an action state.
|
|
154
|
-
# @param name
|
|
155
|
-
# @param action
|
|
196
|
+
# @param name [Symbol] state name
|
|
197
|
+
# @param action [#call, nil] optional entry action shorthand.
|
|
156
198
|
# +state :generate, action: MY_PROC+ is equivalent to
|
|
157
199
|
# +state :generate; entry :generate, MY_PROC+.
|
|
200
|
+
# @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
|
|
201
|
+
# entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
|
|
202
|
+
# Only applies when the action returns a {Task} or {PendingOperation}.
|
|
158
203
|
# @api public
|
|
159
|
-
def state(name, action: nil)
|
|
204
|
+
def state(name, action: nil, action_timeout: nil)
|
|
160
205
|
@declared_states << name
|
|
206
|
+
@action_timeouts[name] = action_timeout if action_timeout
|
|
161
207
|
entry(name, action) if action
|
|
162
208
|
end
|
|
163
209
|
|
|
@@ -309,7 +355,8 @@ module Phronomy
|
|
|
309
355
|
external_events: external_events,
|
|
310
356
|
entry_point: @initial || @declared_states.first,
|
|
311
357
|
wait_state_names: @wait_state_names,
|
|
312
|
-
state_store: @state_store
|
|
358
|
+
state_store: @state_store,
|
|
359
|
+
action_timeouts: @action_timeouts.dup
|
|
313
360
|
)
|
|
314
361
|
|
|
315
362
|
Workflow.new(runner)
|
|
@@ -42,7 +42,16 @@ module Phronomy
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
@fields[name] = {type: type, default: default}
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
# Define getter.
|
|
47
|
+
attr_reader name
|
|
48
|
+
|
|
49
|
+
# Define write-guarded setter. Mutation from outside the EventLoop
|
|
50
|
+
# dispatch thread raises WorkflowContextOwnershipError in EventLoop mode.
|
|
51
|
+
define_method(:"#{name}=") do |value|
|
|
52
|
+
_assert_write_permitted!
|
|
53
|
+
instance_variable_set(:"@#{name}", value)
|
|
54
|
+
end
|
|
46
55
|
end
|
|
47
56
|
|
|
48
57
|
def fields
|
|
@@ -88,7 +97,9 @@ module Phronomy
|
|
|
88
97
|
|
|
89
98
|
self.class.fields.each do |name, config|
|
|
90
99
|
default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
|
|
91
|
-
|
|
100
|
+
# Bypass the write guard in initialize — ownership enforcement begins
|
|
101
|
+
# after construction is complete.
|
|
102
|
+
instance_variable_set(:"@#{name}", attrs.fetch(name, default))
|
|
92
103
|
end
|
|
93
104
|
@thread_id = nil
|
|
94
105
|
@phase = :__end__
|
|
@@ -142,6 +153,22 @@ module Phronomy
|
|
|
142
153
|
|
|
143
154
|
private
|
|
144
155
|
|
|
156
|
+
# Asserts that the calling thread is allowed to mutate this context.
|
|
157
|
+
# No-op when EventLoop mode is disabled.
|
|
158
|
+
# @raise [Phronomy::WorkflowContextOwnershipError] when called from a
|
|
159
|
+
# non-EventLoop thread in EventLoop mode.
|
|
160
|
+
# @api private
|
|
161
|
+
def _assert_write_permitted!
|
|
162
|
+
return unless defined?(Phronomy::EventLoop) &&
|
|
163
|
+
Phronomy.configuration.event_loop
|
|
164
|
+
return if Phronomy::EventLoop.current?
|
|
165
|
+
|
|
166
|
+
raise Phronomy::WorkflowContextOwnershipError,
|
|
167
|
+
"WorkflowContext fields may only be mutated from the EventLoop dispatch " \
|
|
168
|
+
"thread. Use context.merge(...) to produce a new context, or deliver " \
|
|
169
|
+
"updates as event payloads."
|
|
170
|
+
end
|
|
171
|
+
|
|
145
172
|
# Performs a deep copy of a value for immutable context propagation.
|
|
146
173
|
# Arrays and Hashes are deep-duplicated recursively.
|
|
147
174
|
# Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
|