phronomy 0.6.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 +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- 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 +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- 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 +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- 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/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- 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/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -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/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
|
@@ -36,6 +36,7 @@ module Phronomy
|
|
|
36
36
|
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
37
37
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
38
38
|
# @return [McpTool] a configured subclass instance ready for use with an Agent
|
|
39
|
+
# @api public
|
|
39
40
|
def from_server(server_uri, tool_name:)
|
|
40
41
|
# Use a short-lived transport only to query the tool definition,
|
|
41
42
|
# then close it. Each McpTool instance creates its own transport
|
|
@@ -65,13 +66,16 @@ module Phronomy
|
|
|
65
66
|
|
|
66
67
|
def build_tool_class(tool_name, server_uri, tool_def)
|
|
67
68
|
klass = Class.new(McpTool)
|
|
68
|
-
klass.
|
|
69
|
+
klass.tool_name(tool_name)
|
|
69
70
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
70
71
|
|
|
71
72
|
# Register description and params from the MCP tool definition.
|
|
72
73
|
klass.description(tool_def[:description] || tool_name)
|
|
73
74
|
(tool_def[:parameters] || []).each do |p|
|
|
74
|
-
|
|
75
|
+
opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
|
|
76
|
+
opts[:required] = p[:required] if p.key?(:required)
|
|
77
|
+
opts[:enum] = p[:enum] if p.key?(:enum)
|
|
78
|
+
klass.param(p[:name].to_sym, **opts)
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
# Each instance creates its own transport so concurrent agent threads
|
|
@@ -107,15 +111,35 @@ module Phronomy
|
|
|
107
111
|
# so that session state (registered resources, tool context, etc.) is preserved
|
|
108
112
|
# across multiple calls.
|
|
109
113
|
class StdioTransport
|
|
110
|
-
|
|
114
|
+
# @param command [String] shell command to spawn the MCP server process
|
|
115
|
+
# @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
|
|
116
|
+
# before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
|
|
117
|
+
# {HttpTransport}. Defaults to 30 seconds.
|
|
118
|
+
# @param env [Hash, nil] environment variable overrides for the subprocess.
|
|
119
|
+
# When provided, only these variables are added/overridden; the parent environment
|
|
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.
|
|
123
|
+
# @param cwd [String, nil] working directory for the subprocess.
|
|
124
|
+
# Defaults to the current process's working directory.
|
|
125
|
+
# @param startup_timeout [Numeric, nil] seconds to wait for the server to
|
|
126
|
+
# emit its first line on stdout before raising {Phronomy::ToolError}.
|
|
127
|
+
# When nil (default), no startup check is performed.
|
|
128
|
+
# @api public
|
|
129
|
+
def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
|
|
111
130
|
# Split the command string into an argv array so that Open3 executes
|
|
112
131
|
# it directly without going through the shell, preventing injection.
|
|
113
132
|
@command = Shellwords.split(command)
|
|
133
|
+
@read_timeout = read_timeout
|
|
134
|
+
@env = env
|
|
135
|
+
@cwd = cwd
|
|
136
|
+
@startup_timeout = startup_timeout
|
|
114
137
|
@stdin = nil
|
|
115
138
|
@stdout = nil
|
|
116
139
|
@stderr = nil
|
|
117
140
|
@wait_thr = nil
|
|
118
141
|
@stderr_thread = nil
|
|
142
|
+
@stderr_op = nil
|
|
119
143
|
end
|
|
120
144
|
|
|
121
145
|
# Shut down the child process and close its IO streams.
|
|
@@ -127,25 +151,34 @@ module Phronomy
|
|
|
127
151
|
@stdout = nil
|
|
128
152
|
@stderr = nil
|
|
129
153
|
stderr_thread = @stderr_thread
|
|
154
|
+
stderr_op = @stderr_op
|
|
130
155
|
wait_thr = @wait_thr
|
|
131
156
|
@stderr_thread = nil
|
|
157
|
+
@stderr_op = nil
|
|
132
158
|
@wait_thr = nil
|
|
133
159
|
stderr_thread&.join(1)
|
|
160
|
+
begin
|
|
161
|
+
stderr_op&.await(timeout: 1.0)
|
|
162
|
+
rescue
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
134
165
|
wait_thr&.join(5)
|
|
135
166
|
end
|
|
136
167
|
|
|
137
168
|
# Retrieve the tool definition from the server using the MCP `tools/list` method.
|
|
138
169
|
# @param tool_name [String]
|
|
139
170
|
# @return [Hash] { description:, parameters: }
|
|
171
|
+
# @api public
|
|
140
172
|
def fetch_tool(tool_name)
|
|
141
173
|
response = rpc_call("tools/list", {})
|
|
142
174
|
tools = response.dig("result", "tools") || []
|
|
143
175
|
defn = tools.find { |t| t["name"] == tool_name }
|
|
144
176
|
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
|
|
145
177
|
|
|
178
|
+
required_names = defn.dig("inputSchema", "required") || []
|
|
146
179
|
{
|
|
147
180
|
description: defn["description"],
|
|
148
|
-
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
|
|
181
|
+
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
|
|
149
182
|
}
|
|
150
183
|
end
|
|
151
184
|
|
|
@@ -153,6 +186,7 @@ module Phronomy
|
|
|
153
186
|
# @param tool_name [String]
|
|
154
187
|
# @param args [Hash]
|
|
155
188
|
# @return [Object] the tool result
|
|
189
|
+
# @api public
|
|
156
190
|
def call_tool(tool_name, args)
|
|
157
191
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
158
192
|
if response["error"]
|
|
@@ -176,14 +210,47 @@ module Phronomy
|
|
|
176
210
|
def ensure_started!
|
|
177
211
|
return if @stdin && !@stdin.closed?
|
|
178
212
|
|
|
179
|
-
|
|
213
|
+
popen3_opts = {}
|
|
214
|
+
popen3_opts[:chdir] = @cwd if @cwd
|
|
215
|
+
|
|
216
|
+
argv = @env ? [@env, *@command] : @command
|
|
217
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
|
|
180
218
|
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
181
219
|
# and deadlocking the child process. Errors inside the drain thread are
|
|
182
220
|
# silently ignored since stderr content is diagnostics-only.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
if @startup_timeout
|
|
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
|
|
187
254
|
end
|
|
188
255
|
end
|
|
189
256
|
|
|
@@ -191,18 +258,25 @@ module Phronomy
|
|
|
191
258
|
ensure_started!
|
|
192
259
|
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
193
260
|
@stdin.puts(payload)
|
|
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
|
|
194
265
|
raw = @stdout.gets
|
|
195
266
|
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
196
267
|
JSON.parse(raw)
|
|
197
268
|
end
|
|
198
269
|
|
|
199
|
-
def parse_schema_params(properties)
|
|
270
|
+
def parse_schema_params(properties, required_names: [])
|
|
200
271
|
properties.map do |name, schema|
|
|
201
|
-
{
|
|
272
|
+
param = {
|
|
202
273
|
name: name.to_s,
|
|
203
274
|
type: schema["type"] || "string",
|
|
204
|
-
description: schema["description"].to_s
|
|
275
|
+
description: schema["description"].to_s,
|
|
276
|
+
required: required_names.include?(name.to_s)
|
|
205
277
|
}
|
|
278
|
+
param[:enum] = schema["enum"] if schema["enum"]
|
|
279
|
+
param
|
|
206
280
|
end
|
|
207
281
|
end
|
|
208
282
|
end
|
|
@@ -223,10 +297,15 @@ module Phronomy
|
|
|
223
297
|
# @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
|
|
224
298
|
# @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
|
|
225
299
|
# @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
|
|
226
|
-
|
|
300
|
+
# @param headers [Hash] additional HTTP request headers (e.g. Authorization).
|
|
301
|
+
# Merged on top of the default Content-Type and Accept headers; caller-supplied
|
|
302
|
+
# values override defaults when keys collide.
|
|
303
|
+
# @api public
|
|
304
|
+
def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
|
|
227
305
|
@uri = URI.parse(base_url)
|
|
228
306
|
@open_timeout = open_timeout
|
|
229
307
|
@read_timeout = read_timeout
|
|
308
|
+
@extra_headers = headers
|
|
230
309
|
end
|
|
231
310
|
|
|
232
311
|
# HTTP connections are stateless; close is a no-op, defined so that
|
|
@@ -237,15 +316,17 @@ module Phronomy
|
|
|
237
316
|
# Retrieve the tool definition from the server using MCP `tools/list`.
|
|
238
317
|
# @param tool_name [String]
|
|
239
318
|
# @return [Hash] { description:, parameters: }
|
|
319
|
+
# @api public
|
|
240
320
|
def fetch_tool(tool_name)
|
|
241
321
|
response = rpc_call("tools/list", {})
|
|
242
322
|
tools = response.dig("result", "tools") || []
|
|
243
323
|
defn = tools.find { |t| t["name"] == tool_name }
|
|
244
324
|
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
|
|
245
325
|
|
|
326
|
+
required_names = defn.dig("inputSchema", "required") || []
|
|
246
327
|
{
|
|
247
328
|
description: defn["description"],
|
|
248
|
-
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
|
|
329
|
+
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
|
|
249
330
|
}
|
|
250
331
|
end
|
|
251
332
|
|
|
@@ -253,6 +334,7 @@ module Phronomy
|
|
|
253
334
|
# @param tool_name [String]
|
|
254
335
|
# @param args [Hash]
|
|
255
336
|
# @return [Object] the tool result
|
|
337
|
+
# @api public
|
|
256
338
|
def call_tool(tool_name, args)
|
|
257
339
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
258
340
|
if response["error"]
|
|
@@ -285,6 +367,7 @@ module Phronomy
|
|
|
285
367
|
request = Net::HTTP::Post.new(path)
|
|
286
368
|
request["Content-Type"] = "application/json"
|
|
287
369
|
request["Accept"] = "application/json, text/event-stream"
|
|
370
|
+
@extra_headers.each { |k, v| request[k.to_s] = v.to_s }
|
|
288
371
|
request.body = payload
|
|
289
372
|
|
|
290
373
|
http_response = http.request(request)
|
|
@@ -323,13 +406,16 @@ module Phronomy
|
|
|
323
406
|
result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
|
|
324
407
|
end
|
|
325
408
|
|
|
326
|
-
def parse_schema_params(properties)
|
|
409
|
+
def parse_schema_params(properties, required_names: [])
|
|
327
410
|
properties.map do |name, schema|
|
|
328
|
-
{
|
|
411
|
+
param = {
|
|
329
412
|
name: name.to_s,
|
|
330
413
|
type: schema["type"] || "string",
|
|
331
|
-
description: schema["description"].to_s
|
|
414
|
+
description: schema["description"].to_s,
|
|
415
|
+
required: required_names.include?(name.to_s)
|
|
332
416
|
}
|
|
417
|
+
param[:enum] = schema["enum"] if schema["enum"]
|
|
418
|
+
param
|
|
333
419
|
end
|
|
334
420
|
end
|
|
335
421
|
end
|
|
@@ -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
|
|
@@ -23,6 +23,7 @@ module Phronomy
|
|
|
23
23
|
# @param meta [Hash] additional metadata attached to the span
|
|
24
24
|
# @yield [span] the active span object
|
|
25
25
|
# @return [Object] the block's return value
|
|
26
|
+
# @api public
|
|
26
27
|
def trace(name, input: nil, **meta)
|
|
27
28
|
span = start_span(name, input: input, **meta)
|
|
28
29
|
result, usage = yield span
|
|
@@ -38,6 +39,7 @@ module Phronomy
|
|
|
38
39
|
# @param name [String]
|
|
39
40
|
# @param attributes [Hash]
|
|
40
41
|
# @return [Object] an opaque span handle
|
|
42
|
+
# @api public
|
|
41
43
|
def start_span(name, **attributes)
|
|
42
44
|
raise NotImplementedError, "#{self.class}#start_span is not implemented"
|
|
43
45
|
end
|
|
@@ -48,6 +50,7 @@ module Phronomy
|
|
|
48
50
|
# @param output [Object, nil] successful output value
|
|
49
51
|
# @param usage [Phronomy::TokenUsage, nil] token usage for this span
|
|
50
52
|
# @param error [Exception, nil] exception if the block raised
|
|
53
|
+
# @api public
|
|
51
54
|
def finish_span(span, output: nil, usage: nil, error: nil)
|
|
52
55
|
raise NotImplementedError, "#{self.class}#finish_span is not implemented"
|
|
53
56
|
end
|
|
@@ -31,6 +31,7 @@ module Phronomy
|
|
|
31
31
|
# @param public_key [String] Langfuse project public key
|
|
32
32
|
# @param secret_key [String] Langfuse project secret key
|
|
33
33
|
# @param host [String] Langfuse host URL (override for self-hosted instances)
|
|
34
|
+
# @api public
|
|
34
35
|
def initialize(public_key:, secret_key:, host: DEFAULT_HOST)
|
|
35
36
|
@public_key = public_key
|
|
36
37
|
@secret_key = secret_key
|
|
@@ -41,6 +42,7 @@ module Phronomy
|
|
|
41
42
|
# Returns a plain Hash that records the span start state.
|
|
42
43
|
#
|
|
43
44
|
# @return [Hash] an opaque span handle used by {#finish_span}
|
|
45
|
+
# @api public
|
|
44
46
|
def start_span(name, input: nil, **meta)
|
|
45
47
|
{
|
|
46
48
|
id: SecureRandom.uuid,
|
|
@@ -17,6 +17,7 @@ module Phronomy
|
|
|
17
17
|
# end
|
|
18
18
|
class OpenTelemetryTracer < Base
|
|
19
19
|
# @param tracer_name [String] name passed to the OTel TracerProvider
|
|
20
|
+
# @api public
|
|
20
21
|
def initialize(tracer_name: "phronomy")
|
|
21
22
|
require "opentelemetry"
|
|
22
23
|
@otel_tracer = OpenTelemetry.tracer_provider.tracer(tracer_name, Phronomy::VERSION)
|
|
@@ -27,6 +28,7 @@ module Phronomy
|
|
|
27
28
|
# +phronomy.+.
|
|
28
29
|
#
|
|
29
30
|
# @return [OpenTelemetry::Trace::Span]
|
|
31
|
+
# @api public
|
|
30
32
|
def start_span(name, input: nil, **attributes)
|
|
31
33
|
attrs = {}
|
|
32
34
|
attrs["phronomy.input"] = input.to_s if input
|
|
@@ -50,6 +52,40 @@ module Phronomy
|
|
|
50
52
|
end
|
|
51
53
|
span.finish
|
|
52
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
|
|
53
89
|
end
|
|
54
90
|
end
|
|
55
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
|