phronomy 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
> **ARCHIVED / non-normative**
|
|
2
|
+
>
|
|
3
|
+
> This removed/obsolete design is retained only as historical reference. It is
|
|
4
|
+
> not a current architecture or public-API contract. Start at
|
|
5
|
+
> [docs/architecture.md](../../../architecture.md) and the
|
|
6
|
+
> [ADR index](../../../decisions/README.md).
|
|
7
|
+
|
|
8
|
+
# Phronomy — Public API Design
|
|
9
|
+
|
|
10
|
+
> **ARCHIVED — Early Design Draft**
|
|
11
|
+
>
|
|
12
|
+
> This file reflects the intended API as of the initial design phase and has
|
|
13
|
+
> not been updated to track the released implementation. Many examples below
|
|
14
|
+
> use APIs that have been renamed, removed, or changed since this document
|
|
15
|
+
> was written. For the current public API, see [README.md](../../../../README.md).
|
|
16
|
+
>
|
|
17
|
+
> Known divergences (non-exhaustive):
|
|
18
|
+
> - `after :state, to: :next` DSL → current DSL: `transition from: :state, to: :next`
|
|
19
|
+
> - `event :name, from:, to:` DSL → current DSL: `transition from:, on: :name, to:`
|
|
20
|
+
> - `app.send_event(:event, config: { thread_id: })` → current: `app.send_event(state:, event:)`
|
|
21
|
+
> - `Phronomy.chain(...)` / `Phronomy.workflow(...)` shortcut methods → removed
|
|
22
|
+
> - `config.default_state_store`, `config.default_memory` → not implemented
|
|
23
|
+
> - `Phronomy::StateStore::*`, `Phronomy::Memory::WindowMemory` → not in gem
|
|
24
|
+
> - `app.stream(input, config:) { |event| }` → not in current public API
|
|
25
|
+
|
|
26
|
+
## 1. Gem Entry Point
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# lib/phronomy.rb
|
|
30
|
+
|
|
31
|
+
require "ruby_llm"
|
|
32
|
+
require "zeitwerk"
|
|
33
|
+
|
|
34
|
+
loader = Zeitwerk::Loader.for_gem
|
|
35
|
+
loader.setup
|
|
36
|
+
|
|
37
|
+
module Phronomy
|
|
38
|
+
class Error < StandardError; end
|
|
39
|
+
class ParseError < Error; end
|
|
40
|
+
class RecursionLimitError < Error; end
|
|
41
|
+
class GuardrailError < Error; end
|
|
42
|
+
class ToolError < Error
|
|
43
|
+
attr_reader :tool_name, :cause
|
|
44
|
+
def initialize(tool_name:, cause:)
|
|
45
|
+
@tool_name = tool_name
|
|
46
|
+
@cause = cause
|
|
47
|
+
super("Tool #{tool_name} failed: #{cause}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def configuration
|
|
53
|
+
@configuration ||= Configuration.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configure
|
|
57
|
+
yield configuration
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shortcut: returns a Chain
|
|
61
|
+
# @example
|
|
62
|
+
# Phronomy.chain(model: "gpt-4o") do |c|
|
|
63
|
+
# c.prompt "Answer: <%= question %>"
|
|
64
|
+
# c.parse :json
|
|
65
|
+
# end
|
|
66
|
+
def chain(**opts, &block)
|
|
67
|
+
Chain::Builder.new(**opts).tap { |b| block.call(b) if block }.build
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Shortcut: build a Workflow
|
|
71
|
+
# @example
|
|
72
|
+
# app = Phronomy::Workflow.define(MyContext) do
|
|
73
|
+
# initial :fetch
|
|
74
|
+
# state :fetch, action: FETCH_FN
|
|
75
|
+
# end
|
|
76
|
+
def workflow(context_class, &block)
|
|
77
|
+
Workflow.define(context_class, &block)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 2. Configuration API
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Phronomy.configure do |config|
|
|
89
|
+
# Default model (shared with RubyLLM configuration)
|
|
90
|
+
config.default_model = "claude-3-5-sonnet-20241022"
|
|
91
|
+
config.default_embedding_model = "text-embedding-3-small"
|
|
92
|
+
|
|
93
|
+
# Default checkpointer
|
|
94
|
+
config.default_state_store = Phronomy::StateStore::InMemory.new
|
|
95
|
+
|
|
96
|
+
# Default memory
|
|
97
|
+
config.default_memory = Phronomy::Memory::WindowMemory.new(k: 20)
|
|
98
|
+
|
|
99
|
+
# Default tracer
|
|
100
|
+
config.tracer = Phronomy::Tracing::NullTracer.new
|
|
101
|
+
|
|
102
|
+
# Recursion limit
|
|
103
|
+
config.recursion_limit = 25
|
|
104
|
+
|
|
105
|
+
# Default Human-in-the-Loop behavior
|
|
106
|
+
config.interrupt_handler = nil # Set a Proc for default handling
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 3. Chain API
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# === Pattern A: Pipeline via >> operator ===
|
|
116
|
+
prompt = Phronomy::Chain::PromptTemplate.new(
|
|
117
|
+
template: "Please answer the question: <%= question %>",
|
|
118
|
+
system_template: "You are a helpful assistant."
|
|
119
|
+
)
|
|
120
|
+
llm = Phronomy::Chain::LLMChain.new(model: "gpt-4o")
|
|
121
|
+
parser = Phronomy::OutputParser::JsonParser.new
|
|
122
|
+
|
|
123
|
+
chain = prompt >> llm >> parser
|
|
124
|
+
result = chain.invoke(question: "What is Ruby?")
|
|
125
|
+
|
|
126
|
+
# === Pattern B: Builder DSL ===
|
|
127
|
+
chain = Phronomy.chain(model: "claude-3-5-sonnet") do |c|
|
|
128
|
+
c.system "You are an assistant that replies in JSON only."
|
|
129
|
+
c.prompt "Answer the following question in JSON format: <%= question %>"
|
|
130
|
+
c.parse :json
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# === Pattern C: File-based template ===
|
|
134
|
+
prompt = Phronomy::Chain::PromptTemplate.from_file(
|
|
135
|
+
"prompts/research.txt",
|
|
136
|
+
system_path: "prompts/system.txt"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# === Streaming ===
|
|
140
|
+
chain.stream(question: "What are the new features in Ruby 3.4?") do |chunk|
|
|
141
|
+
print chunk
|
|
142
|
+
$stdout.flush
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# === Batch execution ===
|
|
146
|
+
results = chain.batch([
|
|
147
|
+
{ question: "What is Ruby?" },
|
|
148
|
+
{ question: "What is Rails?" },
|
|
149
|
+
{ question: "What is RubyGems?" }
|
|
150
|
+
])
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 4. Workflow API
|
|
156
|
+
|
|
157
|
+
> **STALE DSL NOTE**: `after :state, to: :next` and `event :name, from:, to:`
|
|
158
|
+
> shown below do not exist in the gem. Use `transition from:, to:` (auto-fire)
|
|
159
|
+
> and `transition from:, on: :name, to:` (external event) instead.
|
|
160
|
+
> `app.send_event(:event, config:)` should be `app.send_event(state:, event:)`.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# === Context definition ===
|
|
164
|
+
class MyWorkflowContext
|
|
165
|
+
include Phronomy::WorkflowContext
|
|
166
|
+
|
|
167
|
+
field :input, type: :replace
|
|
168
|
+
field :messages, type: :append, default: -> { [] }
|
|
169
|
+
field :result, type: :replace
|
|
170
|
+
field :done, type: :replace, default: false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# === Workflow definition ===
|
|
174
|
+
app = Phronomy::Workflow.define(MyWorkflowContext) do
|
|
175
|
+
initial :fetch
|
|
176
|
+
|
|
177
|
+
state :fetch, action: ->(s) {
|
|
178
|
+
data = fetch_data(s.input)
|
|
179
|
+
s.merge(messages: [{ role: :user, content: data }])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
state :process, action: ->(s) {
|
|
183
|
+
response = RubyLLM.chat.ask(s.messages.last[:content])
|
|
184
|
+
s.merge(result: response.content, done: true)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
state :retry_handler, action: ->(s) {
|
|
188
|
+
s.merge(messages: [{ role: :user, content: "Please retry." }])
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
after :fetch, to: :process
|
|
192
|
+
# Conditional: retry if not done, else finish
|
|
193
|
+
after :process, to: :retry_handler, guard: ->(s) { !s.done }
|
|
194
|
+
after :process, to: :__finish__
|
|
195
|
+
after :retry_handler, to: :process
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# === Execute ===
|
|
199
|
+
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::ActiveRecord.new(...) }
|
|
200
|
+
|
|
201
|
+
result = app.invoke(
|
|
202
|
+
{ input: "Data to analyze" },
|
|
203
|
+
config: { thread_id: "session_#{user.id}" }
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# === Human-in-the-Loop with wait_state ===
|
|
207
|
+
class ApprovalContext
|
|
208
|
+
include Phronomy::WorkflowContext
|
|
209
|
+
field :input, type: :replace
|
|
210
|
+
field :result, type: :replace
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
app = Phronomy::Workflow.define(ApprovalContext) do
|
|
214
|
+
initial :process
|
|
215
|
+
state :process, action: PROCESS_NODE
|
|
216
|
+
wait_state :awaiting_approval # execution halts here
|
|
217
|
+
state :finalize, action: FINALIZE_NODE
|
|
218
|
+
after :process, to: :awaiting_approval
|
|
219
|
+
after :finalize, to: :__finish__
|
|
220
|
+
event :approve, from: :awaiting_approval, to: :finalize
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# First invocation halts at :awaiting_approval
|
|
224
|
+
state = app.invoke({ input: "..." }, config: { thread_id: "t1" })
|
|
225
|
+
puts "Halted: #{state.halted?}, phase: #{state.phase}"
|
|
226
|
+
|
|
227
|
+
# Human approves — resume via send_event
|
|
228
|
+
app.send_event(:approve, config: { thread_id: "t1" })
|
|
229
|
+
|
|
230
|
+
# === Streaming ===
|
|
231
|
+
app.stream({ input: "..." }, config: { thread_id: "t1" }) do |event|
|
|
232
|
+
case event[:type]
|
|
233
|
+
when :node_start
|
|
234
|
+
puts "-> Executing #{event[:node]}..."
|
|
235
|
+
when :node_end
|
|
236
|
+
puts "\u2713 #{event[:node]} completed"
|
|
237
|
+
when :graph_end
|
|
238
|
+
puts "Workflow execution complete"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 5. Agent API
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# === Declarative definition (class-based) ===
|
|
249
|
+
class CustomerSupportAgent < Phronomy::Agent::Base
|
|
250
|
+
model "claude-3-5-sonnet-20241022"
|
|
251
|
+
temperature 0.5
|
|
252
|
+
max_iterations 15
|
|
253
|
+
|
|
254
|
+
instructions <<~INST
|
|
255
|
+
You are a friendly customer support agent.
|
|
256
|
+
Please resolve user issues politely.
|
|
257
|
+
If you cannot resolve the issue, use the escalation tool.
|
|
258
|
+
INST
|
|
259
|
+
|
|
260
|
+
tools SearchKnowledgeBase,
|
|
261
|
+
CreateTicket,
|
|
262
|
+
EscalateToHuman
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# === Usage ===
|
|
266
|
+
agent = CustomerSupportAgent.new
|
|
267
|
+
result = agent.invoke("What is the status of order #12345?")
|
|
268
|
+
puts result[:output]
|
|
269
|
+
|
|
270
|
+
# === Use as a Workflow state action ===
|
|
271
|
+
class SupportContext
|
|
272
|
+
include Phronomy::WorkflowContext
|
|
273
|
+
field :messages, type: :append, default: -> { [] }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
app = Phronomy::Workflow.define(SupportContext) do
|
|
277
|
+
initial :greet
|
|
278
|
+
state :greet, action: ->(s) { s.merge(messages: ["Hello! How can I help you?"]) }
|
|
279
|
+
state :support, action: ->(s) { s.merge(messages: [CustomerSupportAgent.new.invoke(s.messages.last)[:output]]) }
|
|
280
|
+
after :greet, to: :support
|
|
281
|
+
after :support, to: :__finish__
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 6. Tool API
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
# === Tool definition (inherits from RubyLLM::Tool) ===
|
|
291
|
+
class SearchKnowledgeBase < Phronomy::Tool::Base
|
|
292
|
+
description "Searches the internal knowledge base"
|
|
293
|
+
|
|
294
|
+
param :query, type: :string, desc: "Search query"
|
|
295
|
+
param :limit, type: :integer, desc: "Maximum results", required: false
|
|
296
|
+
param :category, type: :string, desc: "Category filter", required: false
|
|
297
|
+
|
|
298
|
+
# Permission scope definition (optional)
|
|
299
|
+
scope :read_only
|
|
300
|
+
|
|
301
|
+
# Default behavior on failure
|
|
302
|
+
on_error :return_empty
|
|
303
|
+
|
|
304
|
+
def execute(query:, limit: 5, category: nil)
|
|
305
|
+
results = KnowledgeBase.search(query:, limit:, category:)
|
|
306
|
+
results.map { |r| { title: r.title, content: r.body } }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# === Tool requiring human approval ===
|
|
311
|
+
class DeleteCustomerData < Phronomy::Tool::Base
|
|
312
|
+
description "Deletes customer data (requires approval)"
|
|
313
|
+
|
|
314
|
+
param :customer_id, type: :string, desc: "Customer ID"
|
|
315
|
+
|
|
316
|
+
# This tool requires human approval before execution
|
|
317
|
+
requires_approval true
|
|
318
|
+
|
|
319
|
+
def execute(customer_id:)
|
|
320
|
+
Customer.find(customer_id).destroy
|
|
321
|
+
{ success: true, message: "Customer data deleted" }
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# === MCP tool (via external MCP server) ===
|
|
326
|
+
mcp_tool = Phronomy::Tool::McpTool.from_server(
|
|
327
|
+
"stdio://path/to/mcp-server",
|
|
328
|
+
tool_name: "search_web"
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## 7. Memory API
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
# === Basic usage ===
|
|
338
|
+
memory = Phronomy::Memory::WindowMemory.new(k: 20)
|
|
339
|
+
memory.save_messages(
|
|
340
|
+
thread_id: "user_123",
|
|
341
|
+
messages: chat.messages
|
|
342
|
+
)
|
|
343
|
+
past_messages = memory.load_messages(thread_id: "user_123")
|
|
344
|
+
|
|
345
|
+
# === Integration with Agent / Chain ===
|
|
346
|
+
agent = MyAgent.new
|
|
347
|
+
result = agent.invoke(
|
|
348
|
+
"A follow-up question",
|
|
349
|
+
config: {
|
|
350
|
+
thread_id: "user_123",
|
|
351
|
+
memory: Phronomy::Memory::SummaryMemory.new(max_tokens: 4000)
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# === Rails ActiveRecord integration ===
|
|
356
|
+
# config/initializers/phronomy.rb
|
|
357
|
+
Phronomy.configure do |c|
|
|
358
|
+
c.default_memory = Phronomy::Memory::ActiveRecordMemory.new
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## 8. StateStore API
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
# === In-memory (development / testing) ===
|
|
368
|
+
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::InMemory.new }
|
|
369
|
+
|
|
370
|
+
# === ActiveRecord (Rails production) ===
|
|
371
|
+
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::ActiveRecord.new(model_class: PhronmyState) }
|
|
372
|
+
|
|
373
|
+
# === Redis ===
|
|
374
|
+
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::Redis.new(client: Redis.new, ttl: 3600) }
|
|
375
|
+
|
|
376
|
+
# === Manual operations ===
|
|
377
|
+
store = Phronomy::StateStore::InMemory.new
|
|
378
|
+
store.save(state) # state.thread_id is used as key
|
|
379
|
+
state = store.load("t1")
|
|
380
|
+
store.clear("t1")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## 9. Multi-Agent (Agent-as-Tool Pattern)
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
# Sub-agents wrapped as tools so the orchestrator LLM can call them on demand.
|
|
389
|
+
|
|
390
|
+
class ResearchTool < Phronomy::Tool::Base
|
|
391
|
+
description "Research a topic and return key findings as bullet points."
|
|
392
|
+
param :topic, type: :string, desc: "The topic to research"
|
|
393
|
+
|
|
394
|
+
def execute(topic:)
|
|
395
|
+
ResearcherAgent.new.invoke(topic)[:output]
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
class WriteTool < Phronomy::Tool::Base
|
|
400
|
+
description "Write a technical blog post given research notes and a writing brief."
|
|
401
|
+
param :instructions, type: :string, desc: "Writing brief including research notes"
|
|
402
|
+
|
|
403
|
+
def execute(instructions:)
|
|
404
|
+
WriterAgent.new.invoke(instructions)[:output]
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
class OrchestratorAgent < Phronomy::Agent::Base
|
|
409
|
+
model "claude-3-5-sonnet-20241022"
|
|
410
|
+
tools ResearchTool, WriteTool
|
|
411
|
+
instructions "Produce a high-quality blog post. Use research tool first, then write tool."
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 new features")
|
|
415
|
+
puts result[:output]
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## 10. Rails Integration API
|
|
421
|
+
|
|
422
|
+
> **STALE NOTE**: `config.default_state_store`, `config.default_memory`,
|
|
423
|
+
> `acts_as_phronomy_thread`, and `agent.stream(...)` shown below are not
|
|
424
|
+
> implemented in the current gem. See README.md for the Rails example that
|
|
425
|
+
> ships with phronomy-examples.
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
# Gemfile
|
|
429
|
+
gem 'phronomy'
|
|
430
|
+
|
|
431
|
+
# config/initializers/phronomy.rb
|
|
432
|
+
Phronomy.configure do |config|
|
|
433
|
+
config.default_model = "claude-3-5-sonnet-20241022"
|
|
434
|
+
config.default_state_store = Phronomy::StateStore::ActiveRecord.new(model_class: PhronmyState)
|
|
435
|
+
config.default_memory = Phronomy::Memory::ActiveRecordMemory.new
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# migration (generated by rails generate phronomy:install)
|
|
439
|
+
# create_table :phronomy_states
|
|
440
|
+
# create_table :phronomy_messages
|
|
441
|
+
|
|
442
|
+
# ActiveRecord model integration
|
|
443
|
+
class Conversation < ApplicationRecord
|
|
444
|
+
acts_as_phronomy_thread # use the record's id as thread_id
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Usage in a controller
|
|
448
|
+
class ChatController < ApplicationController
|
|
449
|
+
def create
|
|
450
|
+
conversation = Conversation.find(params[:id])
|
|
451
|
+
agent = CustomerSupportAgent.new
|
|
452
|
+
|
|
453
|
+
result = agent.invoke(
|
|
454
|
+
params[:message],
|
|
455
|
+
config: { thread_id: conversation.id.to_s }
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
render json: { response: result[:output] }
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Streaming with ActionCable
|
|
463
|
+
class ChatChannel < ApplicationCable::Channel
|
|
464
|
+
def receive(data)
|
|
465
|
+
agent = CustomerSupportAgent.new
|
|
466
|
+
|
|
467
|
+
agent.stream(data["message"], config: { thread_id: current_user.id.to_s }) do |chunk|
|
|
468
|
+
ActionCable.server.broadcast("chat_#{current_user.id}", { chunk: })
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## 11. Error Handling
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
# Standard exception hierarchy
|
|
480
|
+
Phronomy::Error
|
|
481
|
+
├── Phronomy::ParseError # OutputParser failure
|
|
482
|
+
├── Phronomy::RecursionLimitError # Workflow recursion limit exceeded
|
|
483
|
+
├── Phronomy::GuardrailError # Guardrail violation
|
|
484
|
+
└── Phronomy::ToolError # Tool execution failure
|
|
485
|
+
attr_reader :tool_name, :cause
|
|
486
|
+
|
|
487
|
+
# Halt and resume (no exception raised — use wait_state + send_event)
|
|
488
|
+
app = Phronomy::Workflow.define(MyContext) do
|
|
489
|
+
# ...
|
|
490
|
+
wait_state :awaiting_approval
|
|
491
|
+
event :approve, from: :awaiting_approval, to: :next_step
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
state = app.invoke(input, config: { thread_id: "t1" })
|
|
495
|
+
if state.halted?
|
|
496
|
+
# User reviews state, then resumes
|
|
497
|
+
app.send_event(:approve, config: { thread_id: "t1" })
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
rescue Phronomy::RecursionLimitError
|
|
501
|
+
Rails.logger.error "Workflow recursion limit exceeded"
|
|
502
|
+
rescue Phronomy::ToolError => e
|
|
503
|
+
Rails.logger.error "Tool #{e.tool_name} failed: #{e.cause}"
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
---
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
> **ARCHIVED / non-normative**
|
|
2
|
+
>
|
|
3
|
+
> This removed/obsolete design is retained only as historical reference. It is
|
|
4
|
+
> not a current architecture or public-API contract. Start at
|
|
5
|
+
> [docs/architecture.md](../../../architecture.md) and the
|
|
6
|
+
> [ADR index](../../../decisions/README.md).
|
|
7
|
+
|
|
8
|
+
# Phronomy — Guardrail System
|
|
9
|
+
|
|
10
|
+
## 1. Overview
|
|
11
|
+
|
|
12
|
+
Guardrails are validation hooks that intercept agent inputs and outputs before
|
|
13
|
+
they reach (or leave) the LLM. They provide a clean interception point for
|
|
14
|
+
safety, compliance, and data-quality concerns without modifying core agent logic.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
User input
|
|
18
|
+
│
|
|
19
|
+
[InputGuardrail x N] ← raises GuardrailError on failure
|
|
20
|
+
│
|
|
21
|
+
LLM
|
|
22
|
+
│
|
|
23
|
+
[OutputGuardrail x N] ← raises GuardrailError on failure
|
|
24
|
+
│
|
|
25
|
+
Agent result
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Multiple guardrails of each type may be registered per agent and are executed
|
|
29
|
+
in registration order. The first failure short-circuits the remaining guardrails.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 2. Base Class
|
|
34
|
+
|
|
35
|
+
`lib/phronomy/guardrail/base.rb`
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class Phronomy::Guardrail::Base
|
|
39
|
+
def check(value) # subclasses implement this
|
|
40
|
+
def run!(value) # calls check; returns value unchanged on success
|
|
41
|
+
protected def fail!(reason) # raises Phronomy::GuardrailError
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`GuardrailError` is a standard Ruby exception. It carries:
|
|
46
|
+
- `message` — the human-readable rejection reason
|
|
47
|
+
- `guardrail` — the guardrail instance that raised
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 3. InputGuardrail
|
|
52
|
+
|
|
53
|
+
`lib/phronomy/guardrail/input_guardrail.rb`
|
|
54
|
+
|
|
55
|
+
Applied to user input **before** the LLM receives it.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class NoCreditCardGuardrail < Phronomy::Guardrail::InputGuardrail
|
|
59
|
+
def check(input)
|
|
60
|
+
fail!("Credit card numbers are not allowed") if input.to_s.match?(/\d{4}[- ]\d{4}[- ]\d{4}[- ]\d{4}/)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
agent.add_input_guardrail(NoCreditCardGuardrail.new)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 4. OutputGuardrail
|
|
70
|
+
|
|
71
|
+
`lib/phronomy/guardrail/output_guardrail.rb`
|
|
72
|
+
|
|
73
|
+
Applied to the LLM's output **before** it is returned to the caller.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class NoURLGuardrail < Phronomy::Guardrail::OutputGuardrail
|
|
77
|
+
def check(output)
|
|
78
|
+
fail!("Output must not contain URLs") if output.to_s.match?(/https?:\/\//)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
agent.add_output_guardrail(NoURLGuardrail.new)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 5. Registration API
|
|
88
|
+
|
|
89
|
+
Guardrails are registered on agent instances or via the class-level DSL:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# Instance registration
|
|
93
|
+
agent = MyAgent.new
|
|
94
|
+
agent.add_input_guardrail(PIIPatternDetector.new)
|
|
95
|
+
agent.add_output_guardrail(NoURLGuardrail.new)
|
|
96
|
+
|
|
97
|
+
# Class-level DSL (applied to all instances)
|
|
98
|
+
class MyAgent < Phronomy::Agent::Base
|
|
99
|
+
input_guardrail Phronomy::Guardrail::Builtin::PIIPatternDetector.new
|
|
100
|
+
output_guardrail Phronomy::Guardrail::Builtin::PromptInjectionDetector.new
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 6. Built-in Guardrails
|
|
107
|
+
|
|
108
|
+
`lib/phronomy/guardrail/builtin/`
|
|
109
|
+
|
|
110
|
+
### 6.1 PIIPatternDetector
|
|
111
|
+
|
|
112
|
+
`Phronomy::Guardrail::Builtin::PIIPatternDetector`
|
|
113
|
+
|
|
114
|
+
Detects common PII patterns in the input string via regex (no LLM call).
|
|
115
|
+
|
|
116
|
+
**Categories** (all active by default, each individually toggleable):
|
|
117
|
+
|
|
118
|
+
| Key | Pattern | Description |
|
|
119
|
+
|-----|---------|-------------|
|
|
120
|
+
| `:ssn` | `\d{3}-\d{2}-\d{4}` (hyphens required) | US Social Security Number |
|
|
121
|
+
| `:credit_card` | 16 digits optionally space/hyphen separated | Credit/debit card |
|
|
122
|
+
| `:email` | RFC 5322 simplified | Email address |
|
|
123
|
+
| `:phone` | 3-digit area code + 3–4-digit exchange + 4-digit subscriber; optional E.164 prefix | Phone number |
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# All categories (default)
|
|
127
|
+
Phronomy::Guardrail::Builtin::PIIPatternDetector.new
|
|
128
|
+
|
|
129
|
+
# Only email and credit card
|
|
130
|
+
Phronomy::Guardrail::Builtin::PIIPatternDetector.new(
|
|
131
|
+
detect: [:email, :credit_card]
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 6.2 PromptInjectionDetector
|
|
136
|
+
|
|
137
|
+
`Phronomy::Guardrail::Builtin::PromptInjectionDetector`
|
|
138
|
+
|
|
139
|
+
Detects common prompt injection phrases via a built-in regex list.
|
|
140
|
+
|
|
141
|
+
**Default patterns include:**
|
|
142
|
+
- `ignore all previous instructions`
|
|
143
|
+
- `disregard prior rules`
|
|
144
|
+
- `forget above prompts`
|
|
145
|
+
- `system prompt:`
|
|
146
|
+
- `you are now a/an ...`
|
|
147
|
+
- `act as a/an ...`
|
|
148
|
+
- `pretend you are ...`
|
|
149
|
+
- `jailbreak`, `DAN mode`, `developer mode`
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# Default patterns only
|
|
153
|
+
Phronomy::Guardrail::Builtin::PromptInjectionDetector.new
|
|
154
|
+
|
|
155
|
+
# With additional custom patterns
|
|
156
|
+
Phronomy::Guardrail::Builtin::PromptInjectionDetector.new(
|
|
157
|
+
additional_patterns: [/do anything now/i, /DAN/]
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 7. Error Handling
|
|
164
|
+
|
|
165
|
+
When a guardrail fails, `Phronomy::GuardrailError` is raised. Callers should
|
|
166
|
+
rescue this exception to provide user-facing error messages:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
begin
|
|
170
|
+
result = agent.invoke(user_input)
|
|
171
|
+
rescue Phronomy::GuardrailError => e
|
|
172
|
+
puts "Request blocked: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 8. Design Decisions
|
|
179
|
+
|
|
180
|
+
| Decision | Rationale |
|
|
181
|
+
|----------|-----------|
|
|
182
|
+
| Separate Input/Output subclasses | Makes type intent explicit; avoids guards accidentally applied to wrong phase |
|
|
183
|
+
| `run!` calls `check` | Decouples the check logic from error-raising boilerplate |
|
|
184
|
+
| Built-in PII detector uses regex only | Zero LLM cost; sufficient for structured PII like credit cards and My Number |
|
|
185
|
+
| `fail!` raises immediately | Short-circuits remaining guardrails; fail-fast semantics are safer |
|
|
186
|
+
| Multiple guardrails per agent | Composable; each guardrail has a single responsibility |
|