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
|
@@ -11,7 +11,7 @@ module Phronomy
|
|
|
11
11
|
# Field update policies:
|
|
12
12
|
# :replace (default) -- overwrites with the new value
|
|
13
13
|
# :append -- appends to an Array
|
|
14
|
-
# :merge --
|
|
14
|
+
# :merge -- shallow-merges into a Hash (top-level keys are merged; nested objects are replaced)
|
|
15
15
|
#
|
|
16
16
|
# @example
|
|
17
17
|
# class ScanContext
|
|
@@ -31,9 +31,27 @@ module Phronomy
|
|
|
31
31
|
# @param name [Symbol]
|
|
32
32
|
# @param type [Symbol] :replace / :append / :merge
|
|
33
33
|
# @param default [Object, Proc, nil]
|
|
34
|
+
# @raise [ArgumentError] if +default+ is a plain Array or Hash (use a Proc instead)
|
|
35
|
+
# @api public
|
|
34
36
|
def field(name, type: :replace, default: nil)
|
|
37
|
+
if default.is_a?(Array) || default.is_a?(Hash)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"Mutable default for field #{name.inspect} must be wrapped in a Proc " \
|
|
40
|
+
"to avoid shared state across instances. " \
|
|
41
|
+
"Use `default: -> { #{default.inspect} }` instead."
|
|
42
|
+
end
|
|
43
|
+
|
|
35
44
|
@fields[name] = {type: type, default: default}
|
|
36
|
-
|
|
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
|
|
37
55
|
end
|
|
38
56
|
|
|
39
57
|
def fields
|
|
@@ -51,12 +69,14 @@ module Phronomy
|
|
|
51
69
|
# :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
|
|
52
70
|
# :<state> — resuming at <state> (workflow paused before its execution)
|
|
53
71
|
# @return [Symbol]
|
|
72
|
+
# @api public
|
|
54
73
|
def phase
|
|
55
74
|
@phase || :__end__
|
|
56
75
|
end
|
|
57
76
|
|
|
58
77
|
# Returns true if the workflow is paused mid-execution (not yet completed).
|
|
59
78
|
# @return [Boolean]
|
|
79
|
+
# @api public
|
|
60
80
|
def halted?
|
|
61
81
|
phase != :__end__
|
|
62
82
|
end
|
|
@@ -64,6 +84,7 @@ module Phronomy
|
|
|
64
84
|
# Sets internal workflow metadata. Returns self.
|
|
65
85
|
# @param thread_id [String, nil]
|
|
66
86
|
# @param phase [Symbol, nil]
|
|
87
|
+
# @api public
|
|
67
88
|
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
68
89
|
@thread_id = thread_id unless thread_id.nil?
|
|
69
90
|
@phase = phase unless phase.nil?
|
|
@@ -71,19 +92,32 @@ module Phronomy
|
|
|
71
92
|
end
|
|
72
93
|
|
|
73
94
|
def initialize(**attrs)
|
|
95
|
+
unknown = attrs.keys - self.class.fields.keys
|
|
96
|
+
raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
|
|
97
|
+
|
|
74
98
|
self.class.fields.each do |name, config|
|
|
75
99
|
default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
|
|
76
|
-
|
|
100
|
+
# Bypass the write guard in initialize — ownership enforcement begins
|
|
101
|
+
# after construction is complete.
|
|
102
|
+
instance_variable_set(:"@#{name}", attrs.fetch(name, default))
|
|
77
103
|
end
|
|
78
104
|
@thread_id = nil
|
|
79
105
|
@phase = :__end__
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
108
|
+
# Returns a new context instance with the specified field updates applied.
|
|
109
|
+
# Updated fields follow the field's declared +:type+ semantics (:replace, :append,
|
|
110
|
+
# or :merge). Unchanged fields are deep-copied on a best-effort basis — objects
|
|
111
|
+
# that do not support +#dup+ (e.g. integers, frozen objects) are carried over
|
|
112
|
+
# by reference. Internal workflow metadata (thread_id, phase) is preserved.
|
|
84
113
|
# @param updates [Hash] { field_name => new_value }
|
|
85
114
|
# @return [self.class] new context instance
|
|
115
|
+
# @raise [ArgumentError] if updates contains keys that are not declared fields
|
|
116
|
+
# @api public
|
|
86
117
|
def merge(updates)
|
|
118
|
+
unknown = updates.keys - self.class.fields.keys
|
|
119
|
+
raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
|
|
120
|
+
|
|
87
121
|
new_attrs = {}
|
|
88
122
|
self.class.fields.each_key do |name|
|
|
89
123
|
field_config = self.class.fields[name]
|
|
@@ -97,7 +131,7 @@ module Phronomy
|
|
|
97
131
|
updates[name]
|
|
98
132
|
end
|
|
99
133
|
else
|
|
100
|
-
send(name)
|
|
134
|
+
deep_dup_value(send(name))
|
|
101
135
|
end
|
|
102
136
|
end
|
|
103
137
|
new_context = self.class.new(**new_attrs)
|
|
@@ -110,10 +144,53 @@ module Phronomy
|
|
|
110
144
|
|
|
111
145
|
# Converts user-defined fields to a Hash (excludes internal workflow metadata).
|
|
112
146
|
# @return [Hash]
|
|
147
|
+
# @api public
|
|
113
148
|
def to_h
|
|
114
149
|
self.class.fields.keys.each_with_object({}) do |name, h|
|
|
115
150
|
h[name] = send(name)
|
|
116
151
|
end
|
|
117
152
|
end
|
|
153
|
+
|
|
154
|
+
private
|
|
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
|
+
|
|
172
|
+
# Performs a deep copy of a value for immutable context propagation.
|
|
173
|
+
# Arrays and Hashes are deep-duplicated recursively.
|
|
174
|
+
# Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
|
|
175
|
+
# Other objects are dup'd (best-effort shallow copy for custom types).
|
|
176
|
+
# Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
|
|
177
|
+
def deep_dup_value(val)
|
|
178
|
+
case val
|
|
179
|
+
when Array
|
|
180
|
+
val.map { |v| deep_dup_value(v) }
|
|
181
|
+
when Hash
|
|
182
|
+
val.each_with_object({}) { |(k, v), h| h[k] = deep_dup_value(v) }
|
|
183
|
+
when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
|
|
184
|
+
val
|
|
185
|
+
else
|
|
186
|
+
return val if val.frozen?
|
|
187
|
+
|
|
188
|
+
begin
|
|
189
|
+
val.dup
|
|
190
|
+
rescue TypeError
|
|
191
|
+
val
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
118
195
|
end
|
|
119
196
|
end
|
|
@@ -17,8 +17,11 @@ module Phronomy
|
|
|
17
17
|
# determined by the declared state machine topology, never by Phronomy internals.
|
|
18
18
|
#
|
|
19
19
|
# Entry and exit actions are registered as state_machines +after_transition to:+
|
|
20
|
-
# and +before_transition from:+ callbacks respectively.
|
|
21
|
-
#
|
|
20
|
+
# and +before_transition from:+ callbacks respectively. Entry actions may either
|
|
21
|
+
# mutate the context in place or return a new context (e.g. via +s.merge(...)+).
|
|
22
|
+
# When an entry action returns a Phronomy::WorkflowContext, that value replaces
|
|
23
|
+
# the current context; otherwise the return value is ignored.
|
|
24
|
+
# Exit actions are always mutation-in-place; their return value is ignored.
|
|
22
25
|
#
|
|
23
26
|
# The sole exception is the initial state: state_machines does not fire transition
|
|
24
27
|
# callbacks on initialization, so the entry action for the entry point is invoked
|
|
@@ -35,13 +38,14 @@ module Phronomy
|
|
|
35
38
|
# 2. <event_name> — external events triggered by human input, originating
|
|
36
39
|
# from wait states
|
|
37
40
|
# (declared with +transition from: :awaiting, on: :approve, to: :run+)
|
|
41
|
+
# @api private
|
|
38
42
|
class WorkflowRunner
|
|
39
43
|
include Phronomy::Runnable
|
|
40
44
|
|
|
41
45
|
# Sentinel value for the terminal state of a workflow.
|
|
42
46
|
FINISH = :__end__
|
|
43
47
|
|
|
44
|
-
def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [])
|
|
48
|
+
def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil, action_timeouts: {})
|
|
45
49
|
@state_class = state_class
|
|
46
50
|
@entry_actions = entry_actions # { state_name => [callable, ...] }
|
|
47
51
|
@declared_states = declared_states
|
|
@@ -50,13 +54,16 @@ module Phronomy
|
|
|
50
54
|
@external_events = external_events # { name => [{from:, to:, guard:}, ...] }
|
|
51
55
|
@entry_point = entry_point
|
|
52
56
|
@wait_state_names = wait_state_names
|
|
57
|
+
@state_store = state_store
|
|
58
|
+
@action_timeouts = action_timeouts # { state_name => seconds }
|
|
53
59
|
@phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
# Executes the workflow from the initial state.
|
|
57
63
|
# @param input [Hash] initial context field values
|
|
58
|
-
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
64
|
+
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
|
|
59
65
|
# @return [Object] final context (includes Phronomy::WorkflowContext)
|
|
66
|
+
# @api private
|
|
60
67
|
def invoke(input, config: {})
|
|
61
68
|
caller_meta = {}
|
|
62
69
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
@@ -65,13 +72,23 @@ module Phronomy
|
|
|
65
72
|
trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
|
|
66
73
|
thread_id = config[:thread_id] || SecureRandom.uuid
|
|
67
74
|
recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
|
|
68
|
-
|
|
75
|
+
|
|
76
|
+
store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
|
|
77
|
+
snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
|
|
78
|
+
initial_fields = if snapshot && snapshot[:fields]
|
|
79
|
+
snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
|
|
80
|
+
else
|
|
81
|
+
input
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
state = @state_class.new(**initial_fields)
|
|
69
85
|
state.set_graph_metadata(thread_id: thread_id)
|
|
70
86
|
result = if Phronomy.configuration.event_loop
|
|
71
87
|
run_via_event_loop(state, recursion_limit: recursion_limit)
|
|
72
88
|
else
|
|
73
89
|
run_workflow(state, recursion_limit: recursion_limit)
|
|
74
90
|
end
|
|
91
|
+
store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
|
|
75
92
|
[result, nil]
|
|
76
93
|
end
|
|
77
94
|
end
|
|
@@ -80,6 +97,7 @@ module Phronomy
|
|
|
80
97
|
# @param state [Object] halted context
|
|
81
98
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
82
99
|
# @return [Object] final context
|
|
100
|
+
# @api private
|
|
83
101
|
def resume(state:, input: nil)
|
|
84
102
|
send_event(state: state, event: :resume, input: input)
|
|
85
103
|
end
|
|
@@ -93,6 +111,7 @@ module Phronomy
|
|
|
93
111
|
# @param event [Symbol] named event or +:resume+ for generic resumption
|
|
94
112
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
95
113
|
# @return [Object] final context
|
|
114
|
+
# @api private
|
|
96
115
|
def send_event(state:, event:, input: nil)
|
|
97
116
|
state = state.merge(input) if input
|
|
98
117
|
event = event.to_sym
|
|
@@ -128,6 +147,7 @@ module Phronomy
|
|
|
128
147
|
# @param config [Hash]
|
|
129
148
|
# @yield [Hash]
|
|
130
149
|
# @return [Object] final context
|
|
150
|
+
# @api private
|
|
131
151
|
def stream(input, config: {}, &block)
|
|
132
152
|
thread_id = config[:thread_id] || SecureRandom.uuid
|
|
133
153
|
recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
|
|
@@ -151,6 +171,7 @@ module Phronomy
|
|
|
151
171
|
external_events: @external_events,
|
|
152
172
|
phase_machine_class: @phase_machine_class,
|
|
153
173
|
recursion_limit: recursion_limit,
|
|
174
|
+
action_timeouts: @action_timeouts,
|
|
154
175
|
resume_event: resume_event,
|
|
155
176
|
resume_phase: resume_phase
|
|
156
177
|
)
|
|
@@ -180,6 +201,7 @@ module Phronomy
|
|
|
180
201
|
tracker = new_phase_machine(current_state)
|
|
181
202
|
tracker.context = ctx
|
|
182
203
|
fire_event!(tracker, resume_event, current_state)
|
|
204
|
+
ctx = tracker.context
|
|
183
205
|
next_phase = tracker.phase.to_sym
|
|
184
206
|
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
185
207
|
else
|
|
@@ -189,7 +211,24 @@ module Phronomy
|
|
|
189
211
|
tracker.context = ctx
|
|
190
212
|
# state_machines only fires after_transition callbacks on transitions.
|
|
191
213
|
# The entry point has no prior transition, so we invoke its entry actions directly.
|
|
192
|
-
@entry_actions[current_state]&.each
|
|
214
|
+
@entry_actions[current_state]&.each do |c|
|
|
215
|
+
result = c.call(ctx)
|
|
216
|
+
if result.is_a?(Phronomy::Task)
|
|
217
|
+
timeout_secs = @action_timeouts[current_state]
|
|
218
|
+
if timeout_secs
|
|
219
|
+
if result.join(timeout_secs).nil?
|
|
220
|
+
result.cancel!
|
|
221
|
+
raise Phronomy::ActionTimeoutError,
|
|
222
|
+
"Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
task_result = result.await
|
|
226
|
+
ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
227
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
228
|
+
ctx = result
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
tracker.context = ctx
|
|
193
232
|
end
|
|
194
233
|
|
|
195
234
|
# Event queue: decouple action execution from transition firing.
|
|
@@ -211,6 +250,7 @@ module Phronomy
|
|
|
211
250
|
end
|
|
212
251
|
|
|
213
252
|
fire_event!(tracker, event, current_state)
|
|
253
|
+
ctx = tracker.context
|
|
214
254
|
next_phase = tracker.phase.to_sym
|
|
215
255
|
# When next_phase == current_state no transition matched → terminal state.
|
|
216
256
|
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
@@ -277,11 +317,17 @@ module Phronomy
|
|
|
277
317
|
ext_events = @external_events
|
|
278
318
|
entry_acts = @entry_actions
|
|
279
319
|
exit_acts = exit_actions
|
|
320
|
+
act_timeouts = @action_timeouts # { state_name => seconds }
|
|
280
321
|
|
|
281
322
|
Class.new do
|
|
282
323
|
# Holds the current WorkflowContext so guards and callbacks can read it.
|
|
283
324
|
attr_accessor :context
|
|
284
325
|
|
|
326
|
+
# Set to true by an entry action that returned an awaitable Task.
|
|
327
|
+
# When true, FSMSession skips the automatic advance_or_halt step and
|
|
328
|
+
# waits for the async worker thread to post a state_completed event back.
|
|
329
|
+
attr_accessor :async_pending
|
|
330
|
+
|
|
285
331
|
state_machine :phase, initial: entry do
|
|
286
332
|
all_states.each { |s| state s }
|
|
287
333
|
|
|
@@ -316,10 +362,63 @@ module Phronomy
|
|
|
316
362
|
# Entry callbacks: fire after_transition into each state.
|
|
317
363
|
# Each callable is registered as a separate callback; state_machines
|
|
318
364
|
# accumulates them and fires in declaration order.
|
|
365
|
+
# If the callable returns a WorkflowContext (e.g. via s.merge(...)),
|
|
366
|
+
# the returned context replaces the current one on the tracker.
|
|
319
367
|
entry_acts.each do |state_name, callables|
|
|
320
368
|
callables.each do |callable|
|
|
369
|
+
timeout_secs = act_timeouts[state_name]
|
|
321
370
|
after_transition to: state_name do |machine|
|
|
322
|
-
callable.call(machine.context)
|
|
371
|
+
result = callable.call(machine.context)
|
|
372
|
+
if result.is_a?(Phronomy::Task)
|
|
373
|
+
if Phronomy.configuration.event_loop
|
|
374
|
+
# EventLoop mode: await in a background task so the EventLoop
|
|
375
|
+
# thread is not blocked. Signal async_pending so FSMSession
|
|
376
|
+
# skips the automatic advance_or_halt step.
|
|
377
|
+
machine.async_pending = true
|
|
378
|
+
ctx_ref = machine.context
|
|
379
|
+
thread_id = ctx_ref.thread_id
|
|
380
|
+
Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
|
|
381
|
+
if timeout_secs
|
|
382
|
+
if result.join(timeout_secs).nil?
|
|
383
|
+
result.cancel!
|
|
384
|
+
raise Phronomy::ActionTimeoutError,
|
|
385
|
+
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
task_result = result.await
|
|
389
|
+
if task_result.is_a?(Phronomy::WorkflowContext)
|
|
390
|
+
Phronomy::EventLoop.instance.post(
|
|
391
|
+
Phronomy::Event.new(
|
|
392
|
+
type: :action_completed,
|
|
393
|
+
target_id: thread_id,
|
|
394
|
+
payload: task_result
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
else
|
|
398
|
+
Phronomy::EventLoop.instance.post(
|
|
399
|
+
Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
rescue => e
|
|
403
|
+
Phronomy::EventLoop.instance.post(
|
|
404
|
+
Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
else
|
|
408
|
+
# Non-EventLoop mode: block synchronously on the task result.
|
|
409
|
+
if timeout_secs
|
|
410
|
+
if result.join(timeout_secs).nil?
|
|
411
|
+
result.cancel!
|
|
412
|
+
raise Phronomy::ActionTimeoutError,
|
|
413
|
+
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
task_result = result.await
|
|
417
|
+
machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
418
|
+
end
|
|
419
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
420
|
+
machine.context = result
|
|
421
|
+
end
|
|
323
422
|
end
|
|
324
423
|
end
|
|
325
424
|
end
|
data/lib/phronomy.rb
CHANGED
|
@@ -12,6 +12,10 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
|
|
|
12
12
|
loader.inflector.inflect("fsm_session" => "FSMSession")
|
|
13
13
|
# AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
|
|
14
14
|
loader.inflector.inflect("fsm" => "FSM")
|
|
15
|
+
# LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
|
|
16
|
+
loader.inflector.inflect("llm_adapter" => "LLMAdapter")
|
|
17
|
+
# LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
|
|
18
|
+
loader.inflector.inflect("ruby_llm" => "RubyLLM")
|
|
15
19
|
loader.setup
|
|
16
20
|
|
|
17
21
|
require_relative "phronomy/version"
|
|
@@ -23,11 +27,49 @@ module Phronomy
|
|
|
23
27
|
class ParseError < Error; end
|
|
24
28
|
class RecursionLimitError < Error; end
|
|
25
29
|
class ToolError < Error; end
|
|
30
|
+
# Raised when an agent invocation exceeds the timeout set via +invoke_timeout+.
|
|
31
|
+
class TimeoutError < Error; end
|
|
26
32
|
|
|
27
33
|
class ConfigurationError < Error; end
|
|
28
34
|
|
|
29
35
|
class HandoffError < Error; end
|
|
30
36
|
|
|
37
|
+
# Raised when a network or transport layer call fails (e.g. LLM API unreachable,
|
|
38
|
+
# MCP server connection refused). Distinguishable from application-level errors
|
|
39
|
+
# so callers can apply network-specific retry logic.
|
|
40
|
+
class TransportError < Error; end
|
|
41
|
+
|
|
42
|
+
# Raised when the LLM API returns a rate-limit response (HTTP 429 or equivalent).
|
|
43
|
+
# Callers should back off and retry after the indicated delay.
|
|
44
|
+
class RateLimitError < TransportError; end
|
|
45
|
+
|
|
46
|
+
# Raised when the LLM API rejects the request due to an invalid or revoked API key.
|
|
47
|
+
# Callers should not retry without fixing the credentials.
|
|
48
|
+
class AuthenticationError < TransportError; end
|
|
49
|
+
|
|
50
|
+
# Raised when the prompt exceeds the model's context window limit.
|
|
51
|
+
class ContextLengthError < Error; end
|
|
52
|
+
|
|
53
|
+
# Raised when a workflow or agent execution is explicitly cancelled.
|
|
54
|
+
# Separate from TimeoutError (deadline exceeded) — this is an intentional stop.
|
|
55
|
+
class CancellationError < Error; end
|
|
56
|
+
|
|
57
|
+
# Raised when {Agent#invoke} (a synchronous, blocking call) is attempted from
|
|
58
|
+
# inside an active scheduler task and +strict_runtime_guards+ is enabled.
|
|
59
|
+
#
|
|
60
|
+
# Calling a blocking invocation from within a scheduler task stalls the
|
|
61
|
+
# scheduler until the inner invocation completes, preventing other tasks from
|
|
62
|
+
# making progress (hidden deadlock risk). Use {Agent#invoke_async} followed by
|
|
63
|
+
# +#await+ inside scheduler tasks instead.
|
|
64
|
+
#
|
|
65
|
+
# This error is only raised when:
|
|
66
|
+
# Phronomy.configure { |c| c.strict_runtime_guards = true }
|
|
67
|
+
#
|
|
68
|
+
# By default a warning is logged and execution continues.
|
|
69
|
+
#
|
|
70
|
+
# @see Phronomy::Runtime.in_scheduler_context?
|
|
71
|
+
class SchedulerReentrancyError < Error; end
|
|
72
|
+
|
|
31
73
|
# Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
|
|
32
74
|
# and the pipeline's combined confidence score falls below the configured threshold.
|
|
33
75
|
#
|
|
@@ -54,6 +96,28 @@ module Phronomy
|
|
|
54
96
|
end
|
|
55
97
|
end
|
|
56
98
|
|
|
99
|
+
# Raised when an operation is submitted to a {BlockingAdapterPool} that has
|
|
100
|
+
# already been shut down via {BlockingAdapterPool#shutdown}.
|
|
101
|
+
class PoolShutdownError < Error; end
|
|
102
|
+
|
|
103
|
+
# Raised when a concurrency limit is exceeded and the configured backpressure
|
|
104
|
+
# strategy is +:raise+. The caller should back off and retry.
|
|
105
|
+
class BackpressureError < Error; end
|
|
106
|
+
|
|
107
|
+
# Raised by {CancellationScope#pop_queue} when the deadline expires before a
|
|
108
|
+
# result is available. Extends {TimeoutError} for backwards compatibility.
|
|
109
|
+
class ScopeTimeoutError < TimeoutError; end
|
|
110
|
+
|
|
111
|
+
# Raised when a Workflow entry/exit action task exceeds the +action_timeout:+
|
|
112
|
+
# configured for its state. Extends {TimeoutError}.
|
|
113
|
+
class ActionTimeoutError < TimeoutError; end
|
|
114
|
+
|
|
115
|
+
# Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
|
|
116
|
+
# that does not own the context (i.e. not the EventLoop dispatch thread).
|
|
117
|
+
# Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
|
|
118
|
+
# context, or deliver updates as +:child_completed+ event payloads.
|
|
119
|
+
class WorkflowContextOwnershipError < Error; end
|
|
120
|
+
|
|
57
121
|
class << self
|
|
58
122
|
def configuration
|
|
59
123
|
@configuration ||= Configuration.new
|
|
@@ -63,9 +127,56 @@ module Phronomy
|
|
|
63
127
|
yield configuration
|
|
64
128
|
end
|
|
65
129
|
|
|
66
|
-
# Resets
|
|
130
|
+
# Resets the global Phronomy configuration to defaults.
|
|
131
|
+
#
|
|
132
|
+
# **Intended for test suites only.** Calling this in a production process
|
|
133
|
+
# will drop all runtime configuration (tracer, model, tokenizer, etc.)
|
|
134
|
+
# globally and immediately affect all subsequent agent and workflow calls.
|
|
135
|
+
#
|
|
136
|
+
# **Parallel test suites warning:** When tests run in parallel (e.g.
|
|
137
|
+
# `parallel_tests` or `parallel_rspec`), +reset_configuration!+ in one
|
|
138
|
+
# worker will clear configuration shared with other workers in the same
|
|
139
|
+
# process. Prefer process-isolation strategies (forked workers) over
|
|
140
|
+
# thread-based parallelism when using this method.
|
|
141
|
+
#
|
|
142
|
+
# Typical usage in a sequential test suite:
|
|
143
|
+
# after { Phronomy.reset_configuration! }
|
|
67
144
|
def reset_configuration!
|
|
68
145
|
@configuration = Configuration.new
|
|
69
146
|
end
|
|
147
|
+
|
|
148
|
+
# Yields the current {Configuration} object, then restores the original
|
|
149
|
+
# configuration on exit (even if the block raises).
|
|
150
|
+
#
|
|
151
|
+
# Intended for test helpers that need to temporarily override settings
|
|
152
|
+
# without permanently mutating the global configuration.
|
|
153
|
+
#
|
|
154
|
+
# @yield [config] the current {Configuration} instance (mutable)
|
|
155
|
+
# @example
|
|
156
|
+
# Phronomy.with_configuration do |c|
|
|
157
|
+
# c.logger = Logger.new($stdout)
|
|
158
|
+
# end
|
|
159
|
+
# @api public
|
|
160
|
+
def with_configuration
|
|
161
|
+
original = @configuration&.dup
|
|
162
|
+
yield configuration
|
|
163
|
+
ensure
|
|
164
|
+
@configuration = original
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Resets all Phronomy runtime state: configuration and the EventLoop
|
|
168
|
+
# singleton (if running).
|
|
169
|
+
#
|
|
170
|
+
# **Intended for test suites only.** Stops any running EventLoop thread,
|
|
171
|
+
# clears the EventLoop singleton, and resets configuration to defaults.
|
|
172
|
+
# Call once before/after each example to ensure test isolation.
|
|
173
|
+
#
|
|
174
|
+
# @example
|
|
175
|
+
# config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
|
|
176
|
+
# @api public
|
|
177
|
+
def reset_runtime!
|
|
178
|
+
Phronomy::EventLoop.reset!
|
|
179
|
+
@configuration = Configuration.new
|
|
180
|
+
end
|
|
70
181
|
end
|
|
71
182
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# scripts/api_snapshot.rb
|
|
5
|
+
#
|
|
6
|
+
# Dumps the public instance methods of all Stable/Beta public API classes to
|
|
7
|
+
# JSON. The snapshot is stored in spec/fixtures/api_snapshot.json and is used
|
|
8
|
+
# by spec/phronomy/api_compatibility_spec.rb to detect unintended API removals.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# # Regenerate spec/fixtures/api_snapshot.json (run when intentionally adding
|
|
12
|
+
# # or removing public API methods after updating the stability table):
|
|
13
|
+
# ruby scripts/api_snapshot.rb --write
|
|
14
|
+
#
|
|
15
|
+
# # Print snapshot to stdout (useful for manual inspection):
|
|
16
|
+
# ruby scripts/api_snapshot.rb
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require "fileutils"
|
|
20
|
+
require_relative "../lib/phronomy"
|
|
21
|
+
|
|
22
|
+
# Classes and modules whose public API is tracked.
|
|
23
|
+
# Add an entry whenever a new class/module is promoted to Stable or Beta in README.md.
|
|
24
|
+
PUBLIC_API_ENTRIES = [
|
|
25
|
+
# Stable
|
|
26
|
+
Phronomy::Agent::Base,
|
|
27
|
+
Phronomy::Tool::Base,
|
|
28
|
+
Phronomy::Workflow,
|
|
29
|
+
Phronomy::WorkflowContext,
|
|
30
|
+
Phronomy::Runnable,
|
|
31
|
+
Phronomy::PromptTemplate,
|
|
32
|
+
# Beta
|
|
33
|
+
Phronomy::Agent::ReactAgent,
|
|
34
|
+
Phronomy::Agent::Orchestrator,
|
|
35
|
+
Phronomy::Agent::TeamCoordinator,
|
|
36
|
+
Phronomy::Guardrail::InputGuardrail,
|
|
37
|
+
Phronomy::Guardrail::OutputGuardrail,
|
|
38
|
+
Phronomy::VectorStore::Base,
|
|
39
|
+
Phronomy::VectorStore::InMemory,
|
|
40
|
+
Phronomy::Embeddings::Base,
|
|
41
|
+
Phronomy::KnowledgeSource::Base,
|
|
42
|
+
Phronomy::KnowledgeSource::StaticKnowledge,
|
|
43
|
+
Phronomy::KnowledgeSource::RAGKnowledge,
|
|
44
|
+
Phronomy::Tracing::Base,
|
|
45
|
+
Phronomy::Tracing::NullTracer,
|
|
46
|
+
Phronomy::Eval::Runner
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Baseline methods common to all Ruby objects — excluded from the snapshot.
|
|
50
|
+
BASELINE_INSTANCE_METHODS = (
|
|
51
|
+
Object.public_instance_methods |
|
|
52
|
+
Kernel.public_instance_methods
|
|
53
|
+
).uniq.freeze
|
|
54
|
+
|
|
55
|
+
BASELINE_CLASS_METHODS = (
|
|
56
|
+
Class.public_methods |
|
|
57
|
+
Module.public_methods
|
|
58
|
+
).uniq.freeze
|
|
59
|
+
|
|
60
|
+
def snapshot_entry(klass)
|
|
61
|
+
if klass.instance_of?(Module)
|
|
62
|
+
# Module — capture instance methods defined in this module only
|
|
63
|
+
own_methods = klass.public_instance_methods(false).sort
|
|
64
|
+
{
|
|
65
|
+
"name" => klass.name,
|
|
66
|
+
"type" => "module",
|
|
67
|
+
"public_instance_methods" => own_methods
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
# Class — capture public instance methods minus universal baseline
|
|
71
|
+
instance_methods = (klass.public_instance_methods - BASELINE_INSTANCE_METHODS).sort
|
|
72
|
+
class_methods = (klass.public_methods(false) - BASELINE_CLASS_METHODS).sort
|
|
73
|
+
{
|
|
74
|
+
"name" => klass.name,
|
|
75
|
+
"type" => "class",
|
|
76
|
+
"public_instance_methods" => instance_methods,
|
|
77
|
+
"public_class_methods" => class_methods
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
snapshot = PUBLIC_API_ENTRIES.map { |entry| snapshot_entry(entry) }
|
|
83
|
+
|
|
84
|
+
if ARGV.include?("--write")
|
|
85
|
+
path = File.expand_path("../spec/fixtures/api_snapshot.json", __dir__)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
87
|
+
File.write(path, JSON.pretty_generate(snapshot) + "\n")
|
|
88
|
+
puts "Wrote #{path}"
|
|
89
|
+
else
|
|
90
|
+
puts JSON.pretty_generate(snapshot)
|
|
91
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# check_api_annotations.rb
|
|
5
|
+
#
|
|
6
|
+
# Verifies that every YARD-documented public method in lib/ carries either
|
|
7
|
+
# "@api public" or "@api private".
|
|
8
|
+
#
|
|
9
|
+
# A method is considered "YARD-documented" when its preceding comment block
|
|
10
|
+
# contains at least one @param, @return, @raise, @yield, @example, or
|
|
11
|
+
# @overload tag. Methods with only a plain prose description (no @ tags)
|
|
12
|
+
# are exempt.
|
|
13
|
+
#
|
|
14
|
+
# Usage (run from the phronomy/ repository root):
|
|
15
|
+
# ruby scripts/check_api_annotations.rb
|
|
16
|
+
#
|
|
17
|
+
# Exit codes:
|
|
18
|
+
# 0 — all documented methods carry @api annotations
|
|
19
|
+
# 1 — one or more documented methods are missing @api annotations
|
|
20
|
+
|
|
21
|
+
lib_dir = File.expand_path("../lib", __dir__)
|
|
22
|
+
|
|
23
|
+
unless File.directory?(lib_dir)
|
|
24
|
+
warn "ERROR: lib directory not found at #{lib_dir}"
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
errors = []
|
|
29
|
+
|
|
30
|
+
Dir.glob(File.join(lib_dir, "**", "*.rb")).sort.each do |file|
|
|
31
|
+
lines = File.readlines(file)
|
|
32
|
+
|
|
33
|
+
lines.each_with_index do |line, i|
|
|
34
|
+
next unless line.match?(/^\s*def\s+\w/)
|
|
35
|
+
|
|
36
|
+
# Collect the contiguous comment block immediately above this def.
|
|
37
|
+
comment_lines = []
|
|
38
|
+
j = i - 1
|
|
39
|
+
while j >= 0 && lines[j].match?(/^\s*#/)
|
|
40
|
+
comment_lines.unshift(lines[j])
|
|
41
|
+
j -= 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
next if comment_lines.empty?
|
|
45
|
+
|
|
46
|
+
comment = comment_lines.join
|
|
47
|
+
|
|
48
|
+
# Only lint methods that carry at least one YARD type tag.
|
|
49
|
+
next unless comment.match?(/#[ \t]+@(param|return|raise|yield|example|overload)/)
|
|
50
|
+
|
|
51
|
+
# Pass if an @api tag is already present.
|
|
52
|
+
next if comment.match?(/#[ \t]+@api[ \t]+(public|private)/)
|
|
53
|
+
|
|
54
|
+
rel_path = file.sub("#{lib_dir}/../", "")
|
|
55
|
+
m = line.match(/def\s+(\w+[!?=]?)/)
|
|
56
|
+
method_name = m ? m[1] : "unknown"
|
|
57
|
+
errors << "#{rel_path}:#{i + 1} def #{method_name} (missing @api public or @api private)"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if errors.empty?
|
|
62
|
+
puts "OK: all YARD-documented methods carry @api annotations"
|
|
63
|
+
exit 0
|
|
64
|
+
else
|
|
65
|
+
puts "FAIL: #{errors.size} method(s) missing @api annotation:"
|
|
66
|
+
errors.each { |e| puts " #{e}" }
|
|
67
|
+
exit 1
|
|
68
|
+
end
|