phronomy 0.2.2 → 0.3.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/CHANGELOG.md +88 -30
- data/README.md +26 -110
- data/lib/phronomy/agent/base.rb +127 -54
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
- data/lib/phronomy/railtie.rb +0 -6
- data/lib/phronomy/ruby_llm_patches.rb +20 -0
- data/lib/phronomy/tool/mcp_tool.rb +23 -26
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
- data/lib/phronomy/trust_pipeline.rb +1 -2
- data/lib/phronomy/vector_store/redis_search.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +4 -7
- data/lib/phronomy/workflow_runner.rb +1 -8
- data/lib/phronomy.rb +1 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +5 -33
- data/docs/trustworthy_ai_enhancements.md +0 -332
- data/lib/phronomy/active_record/acts_as.rb +0 -48
- data/lib/phronomy/active_record/checkpoint.rb +0 -20
- data/lib/phronomy/active_record/extensions.rb +0 -14
- data/lib/phronomy/active_record/message.rb +0 -20
- data/lib/phronomy/actor.rb +0 -68
- data/lib/phronomy/memory/compression/base.rb +0 -37
- data/lib/phronomy/memory/compression/summary.rb +0 -107
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
- data/lib/phronomy/memory/compression.rb +0 -11
- data/lib/phronomy/memory/conversation_manager.rb +0 -213
- data/lib/phronomy/memory/retrieval/base.rb +0 -22
- data/lib/phronomy/memory/retrieval/composite.rb +0 -76
- data/lib/phronomy/memory/retrieval/recent.rb +0 -35
- data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
- data/lib/phronomy/memory/retrieval.rb +0 -12
- data/lib/phronomy/memory/storage/active_record.rb +0 -248
- data/lib/phronomy/memory/storage/base.rb +0 -155
- data/lib/phronomy/memory/storage/in_memory.rb +0 -152
- data/lib/phronomy/memory/storage.rb +0 -11
- data/lib/phronomy/memory.rb +0 -21
- data/lib/phronomy/rails/agent_job.rb +0 -75
- data/lib/phronomy/state_store/active_record.rb +0 -76
- data/lib/phronomy/state_store/base.rb +0 -112
- data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
- data/lib/phronomy/state_store/encryptor/base.rb +0 -34
- data/lib/phronomy/state_store/encryptor.rb +0 -16
- data/lib/phronomy/state_store/file.rb +0 -85
- data/lib/phronomy/state_store/in_memory.rb +0 -53
- data/lib/phronomy/state_store/redis.rb +0 -70
- data/lib/phronomy/state_store.rb +0 -9
- data/lib/phronomy/thread_actor_registry.rb +0 -85
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bb874213c4687c9021be3c78d8972218ed56980cfff777a624311ce476d7314
|
|
4
|
+
data.tar.gz: ab3017e56357b057943d31a557e9e1cd12555ec13924fbee92c6f0f7791c9bd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3d71a750858fda7910addd2ea8de1a3b907e746a247635d0b7467b4ffb5cf1ca970e74a08118b58e950c5843f756462d87a324331d23f50720067a83bb87590
|
|
7
|
+
data.tar.gz: 5ce1868de692cd6807c910f3d4669791307564c5f3dc58055c82c4c0737e3696c0d5b1050e7b85f1aba30b1e6309c11e66fcbf7ffc4f9f6c63f3970b5bce2d52
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-05-18
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
|
|
14
|
+
- **`Phronomy::Memory` module fully removed**: `ConversationManager`, all
|
|
15
|
+
`Storage` backends (InMemory, ActiveRecord), all `Retrieval` strategies
|
|
16
|
+
(Recent, Semantic, Composite), and all `Compression` helpers (ToolOutputPruner,
|
|
17
|
+
Summary) have been deleted. Conversation history is now the responsibility of
|
|
18
|
+
the calling application — pass prior messages via `config[:messages]`
|
|
19
|
+
(`Array<RubyLLM::Message>`) and receive the updated array in `result[:messages]`.
|
|
20
|
+
- **`Phronomy::StateStore` module fully removed**: `InMemory`, `ActiveRecord`,
|
|
21
|
+
`Redis`, and `FileSystem` state-store backends have been deleted. The Workflow
|
|
22
|
+
halted-state object is now returned directly from `invoke` and `send_event`
|
|
23
|
+
and must be stored by the caller if resumption is needed.
|
|
24
|
+
- **`Phronomy::Configuration#default_state_store` removed**: No longer meaningful
|
|
25
|
+
without a built-in state store.
|
|
26
|
+
- **`Phronomy::Configuration#default_memory` / `#memory_async` / `#memory_job_queue` removed**:
|
|
27
|
+
No longer meaningful without the Memory module.
|
|
28
|
+
- **Rails integration removed**: `Railtie` initializers for `AgentJob` and
|
|
29
|
+
`acts_as_phronomy_message` no longer load. The `rails/` and `active_record/`
|
|
30
|
+
directories have been deleted.
|
|
31
|
+
- **`Phronomy::Actor` and `Phronomy::ThreadActorRegistry` deleted**: The Active
|
|
32
|
+
Object pattern implementation (`actor.rb`, `thread_actor_registry.rb`) has been
|
|
33
|
+
removed. It provided synchronous blocking only (no true async) and was
|
|
34
|
+
architecturally inconsistent with the `WorkflowRunner` halt/resume model. All
|
|
35
|
+
thread coordination now uses plain `Mutex` where needed.
|
|
36
|
+
- **`Phronomy.configuration.max_actors` removed**: The configuration option is no
|
|
37
|
+
longer meaningful without `ThreadActorRegistry`.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`Agent::Base#invoke` and `#stream`** no longer route execution through a
|
|
42
|
+
per-thread Actor. Both methods now call `_invoke_impl` / `_stream_impl` directly
|
|
43
|
+
on the calling thread.
|
|
44
|
+
- **`Memory::Storage::InMemory`** now stores all thread data in an instance-level
|
|
45
|
+
`Hash` instead of `Thread.current` thread-local storage. The class-level
|
|
46
|
+
`THREAD_DATA_KEY` constant has been removed. `with_thread_lock` uses a
|
|
47
|
+
per-thread-id `Mutex` to preserve concurrent-compaction safety (issue #44).
|
|
48
|
+
- **`StateStore::InMemory`** now stores state in an instance-level `Hash`.
|
|
49
|
+
The `THREAD_DATA_KEY` constant has been removed.
|
|
50
|
+
- **`VectorStore::RedisSearch`** uses a `Mutex` for `ensure_index!` and `clear`
|
|
51
|
+
instead of an Actor, preserving the thread-safety invariant on `@index_created`.
|
|
52
|
+
- **`Tool::McpTool::StdioTransport`**, **`Tracing::LangfuseTracer`**,
|
|
53
|
+
**`TrustPipeline`**, and **`Memory::Retrieval::Semantic`** no longer hold a
|
|
54
|
+
dedicated Actor instance. All operations execute directly on the calling thread.
|
|
55
|
+
- **`PIIPatternDetector` — `:my_number` replaced by `:ssn`** ([#77]): The built-in PII
|
|
56
|
+
detector now checks for US Social Security Numbers (`\b\d{3}-\d{2}-\d{4}\b`) instead
|
|
57
|
+
of Japanese My Numbers. The JIS X 0076 check-digit validation and `my_number_valid?`
|
|
58
|
+
helper have been removed. Category key renamed from `:my_number` to `:ssn`.
|
|
59
|
+
- **`PIIPatternDetector` — phone pattern updated to international format** ([#77]):
|
|
60
|
+
The `:phone` pattern now matches 3-digit area code + 3–4-digit exchange + 4-digit
|
|
61
|
+
subscriber number with optional E.164 country-code prefix
|
|
62
|
+
(`(?:\+\d{1,3}[.\- ]?)?\(?\d{3}\)?[.\- ]?\d{3,4}[.\- ]?\d{4}\b`),
|
|
63
|
+
replacing the previous Japan-specific pattern.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **`RubyLLM::Providers::OpenAI#handle_error_chunk` — `NoMethodError` on single-line SSE error chunks**:
|
|
68
|
+
Some models (e.g. Qwen running via LM Studio) return SSE error events as a
|
|
69
|
+
single line (`data: {...}`) without a preceding `event:` line. The upstream
|
|
70
|
+
implementation called `chunk.split("\n")[1].delete_prefix(...)`, which raised
|
|
71
|
+
`NoMethodError: undefined method 'delete_prefix' for nil` when the second
|
|
72
|
+
element was absent. A monkey-patch in `lib/phronomy/ruby_llm_patches.rb` guards
|
|
73
|
+
against this by returning an empty string when the split result has fewer than
|
|
74
|
+
two elements.
|
|
75
|
+
- **`README` — stale Memory API examples** ([#76]): All references to the
|
|
76
|
+
non-existent `WindowMemory`, `ActiveRecordMemory`, `SemanticMemory` classes and
|
|
77
|
+
`load_messages` / `memory_compression` API have been replaced with the correct
|
|
78
|
+
`ConversationManager`-based API.
|
|
79
|
+
- **`README` — `PIIPatternDetector` comment** ([#77]): Inline comment updated to
|
|
80
|
+
`# Detect SSNs, credit cards, emails, and phone numbers`.
|
|
81
|
+
- **`README` — Configuration block markdown** ([#80]): The `max_actors` Note block
|
|
82
|
+
was incorrectly placed inside the Ruby code fence; moved outside so it renders
|
|
83
|
+
as a blockquote.
|
|
84
|
+
- **`README` — `Guardrails` stability label** ([#76]): Changed from `Stable` to `Beta`
|
|
85
|
+
to reflect that the built-in detector patterns may evolve.
|
|
86
|
+
- **`CHANGELOG` — stale entries** ([#78]): Removed the orphaned `[Unreleased]` section
|
|
87
|
+
describing a never-released API, and replaced a forward `"As of 0.3.0"` reference
|
|
88
|
+
with future-tense wording.
|
|
89
|
+
- **`McpTool` — YARD class comment** ([#79]): Updated to document both the
|
|
90
|
+
`stdio://` and `http://`/`https://` transport schemes.
|
|
91
|
+
- **`README` — `max_actors` configuration reference** ([#80]): Added `c.max_actors`
|
|
92
|
+
example and LRU eviction note to the Configuration section.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
10
96
|
## [0.2.2] - 2026-05-17
|
|
11
97
|
|
|
12
98
|
### Fixed
|
|
@@ -61,8 +147,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
61
147
|
- **`WorkflowRunner` — state_machines fully drives execution** (architecture overhaul).
|
|
62
148
|
Previously `state_machines` was used only for post-hoc transition validation;
|
|
63
149
|
the next-node was calculated by Phronomy internally (`resolve_next_node`).
|
|
64
|
-
|
|
65
|
-
routing events —
|
|
150
|
+
After this change, all state transition decisions — including guard evaluation for
|
|
151
|
+
routing events — will be delegated entirely to `state_machines`.
|
|
66
152
|
- `PhaseTracker` now exposes `attr_accessor :context` so guard lambdas can
|
|
67
153
|
access the `WorkflowContext` via `m.context`.
|
|
68
154
|
- Guard bridge pattern: `if: ->(m) { guard_proc.call(m.context) }`.
|
|
@@ -87,34 +173,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
87
173
|
|
|
88
174
|
---
|
|
89
175
|
|
|
90
|
-
## [Unreleased]
|
|
91
|
-
|
|
92
|
-
### Added
|
|
93
|
-
|
|
94
|
-
- **`Phronomy::Graph::Context`** module — canonical module for defining workflow
|
|
95
|
-
context classes (replaces the removed `Phronomy::Graph::State`).
|
|
96
|
-
- **`Phronomy::Graph.register_context_class`** — registers context classes for
|
|
97
|
-
deserialization from external stores (Redis, DB).
|
|
98
|
-
- **`Phronomy::Workflow.define`** DSL — primary high-level API for declaring
|
|
99
|
-
stateful workflows (`state`, `wait_state`, `event`, `after`, `initial`).
|
|
100
|
-
- **`Phronomy::Graph::WorkflowRunner`** — state-machine execution engine backing
|
|
101
|
-
the Workflow DSL. Replaces the removed `CompiledGraph`.
|
|
102
|
-
- **`app.send_event(event, config:)`** — event-driven resume for workflows halted
|
|
103
|
-
at a `wait_state`.
|
|
104
|
-
- **`state.halted?`** — returns `true` when the workflow is paused at a `wait_state`.
|
|
105
|
-
- **`state.phase`** — single source of truth for execution state.
|
|
106
|
-
|
|
107
|
-
### Removed
|
|
108
|
-
|
|
109
|
-
- `Phronomy::Graph::StateGraph` / `CompiledGraph` — use `Phronomy::Workflow.define`.
|
|
110
|
-
- `Phronomy::Graph::State` — use `Phronomy::Graph::Context`.
|
|
111
|
-
- `Phronomy::Graph.register_state_class` — use `register_context_class`.
|
|
112
|
-
- `state.current_nodes` / `state.halted_before` — use `state.phase` / `state.halted?`.
|
|
113
|
-
- `compiled.interrupt_before` / `compiled.interrupt_after` — use `wait_state` + `event`.
|
|
114
|
-
- `compiled.resume` — use `app.send_event`.
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
176
|
## [0.2.0] - 2026-05-13
|
|
119
177
|
|
|
120
178
|
### Added
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Phronomy
|
|
2
2
|
|
|
3
3
|
**Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
|
|
4
|
-
It provides composable building blocks — Workflows, Agents, and
|
|
4
|
+
It provides composable building blocks — Workflows, Agents, Tools, Guardrails, RAG, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
@@ -13,21 +13,17 @@ It provides composable building blocks — Workflows, Agents, and Memory — all
|
|
|
13
13
|
|---|---|
|
|
14
14
|
| **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
|
|
15
15
|
| **Workflow Parallel Node** — Concurrent branches via application-level threads | Beta |
|
|
16
|
-
| **Agent** — ReAct-style tool-calling agents with
|
|
16
|
+
| **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
|
|
17
17
|
| **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
|
|
18
|
-
| **Memory** — Window, summary, ActiveRecord-backed, semantic, and composite memory | Stable |
|
|
19
|
-
| **Memory Compression** — Automatic summarisation and tool-output pruning | Beta |
|
|
20
18
|
| **Context Management** — Token budget calculation, estimation, and pruning | Stable |
|
|
21
19
|
| **Knowledge/RAG** — Retrieval sources with pluggable loaders, splitters, and vector stores | Beta |
|
|
22
20
|
| **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
|
|
23
21
|
| **TrustPipeline** — Self-review loop and confidence gate (citations are LLM-self-reported) | Experimental |
|
|
24
|
-
| **Guardrails** — Input/output validation; built-in PII and prompt-injection detectors |
|
|
22
|
+
| **Guardrails** — Input/output validation; built-in PII and prompt-injection detectors | Beta |
|
|
25
23
|
| **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
|
|
26
24
|
| **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
|
|
27
25
|
| **Tracing** — Pluggable span-based observability | Stable |
|
|
28
|
-
| **StateStore** — Persist graph state to memory, ActiveRecord, Redis, or file system | Stable |
|
|
29
26
|
| **MCP Tool** — Model Context Protocol server integration | Beta |
|
|
30
|
-
| **Rails integration** — `AgentJob`, `acts_as_phronomy_message`, and generators | Beta |
|
|
31
27
|
|
|
32
28
|
## Installation
|
|
33
29
|
|
|
@@ -49,7 +45,7 @@ For Rails apps, run the install generator after bundling:
|
|
|
49
45
|
rails generate phronomy:install
|
|
50
46
|
```
|
|
51
47
|
|
|
52
|
-
This creates
|
|
48
|
+
This creates a configuration initializer.
|
|
53
49
|
|
|
54
50
|
## Quick Start
|
|
55
51
|
|
|
@@ -99,8 +95,6 @@ app = Phronomy::Workflow.define(ReviewContext) do
|
|
|
99
95
|
event :reject, from: :awaiting_approval, to: :write
|
|
100
96
|
end
|
|
101
97
|
|
|
102
|
-
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::InMemory.new }
|
|
103
|
-
|
|
104
98
|
# First run — halts at :awaiting_approval
|
|
105
99
|
state = app.invoke({ draft: "" }, config: { thread_id: "doc-1" })
|
|
106
100
|
puts "Halted: #{state.halted?}" # => true
|
|
@@ -160,7 +154,7 @@ agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
|
|
|
160
154
|
### Built-in Guardrails — PII and prompt injection detection
|
|
161
155
|
|
|
162
156
|
```ruby
|
|
163
|
-
# Detect credit cards,
|
|
157
|
+
# Detect SSNs, credit cards, emails, and phone numbers
|
|
164
158
|
agent.add_input_guardrail(Phronomy::Guardrail::Builtin::PIIPatternDetector.new)
|
|
165
159
|
|
|
166
160
|
# Block common prompt-injection attempts
|
|
@@ -332,35 +326,28 @@ search_tool = Phronomy::Tool::McpTool.from_server(
|
|
|
332
326
|
)
|
|
333
327
|
```
|
|
334
328
|
|
|
335
|
-
###
|
|
336
|
-
|
|
337
|
-
```ruby
|
|
338
|
-
# In your migration (generated by rails generate phronomy:install):
|
|
339
|
-
# create_table :phronomy_messages ...
|
|
340
|
-
# create_table :phronomy_states ...
|
|
329
|
+
### Conversation History — passing prior messages
|
|
341
330
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
end
|
|
331
|
+
Phronomy does not manage conversation history internally. Instead, the application owns the
|
|
332
|
+
message array and passes it in via `config[:messages]`:
|
|
345
333
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
result = agent.invoke(
|
|
356
|
-
params[:message],
|
|
357
|
-
config: {
|
|
358
|
-
thread_id: "user_#{current_user.id}",
|
|
359
|
-
memory: PhronomyMessage.phronomy_memory
|
|
360
|
-
}
|
|
334
|
+
```ruby
|
|
335
|
+
# First turn
|
|
336
|
+
result1 = MyAgent.new.invoke("Hello! I'm Alice.", config: { thread_id: "session-1" })
|
|
337
|
+
prior_messages = result1[:messages] # Array<RubyLLM::Message>
|
|
338
|
+
|
|
339
|
+
# Second turn — pass prior messages so the agent has context
|
|
340
|
+
result2 = MyAgent.new.invoke(
|
|
341
|
+
"What is my name?",
|
|
342
|
+
config: { messages: prior_messages, thread_id: "session-1" }
|
|
361
343
|
)
|
|
344
|
+
puts result2[:output] # => "Your name is Alice."
|
|
362
345
|
```
|
|
363
346
|
|
|
347
|
+
`result[:messages]` contains the complete message history after each invocation.
|
|
348
|
+
Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
|
|
349
|
+
|
|
350
|
+
|
|
364
351
|
## Configuration
|
|
365
352
|
|
|
366
353
|
```ruby
|
|
@@ -368,9 +355,7 @@ Phronomy.configure do |c|
|
|
|
368
355
|
c.default_model = "gpt-4o-mini"
|
|
369
356
|
c.recursion_limit = 25
|
|
370
357
|
c.tracer = Phronomy::Tracing::NullTracer.new
|
|
371
|
-
c.
|
|
372
|
-
c.memory_compression = [] # optional; Array of compressors
|
|
373
|
-
c.before_completion = nil # optional; global hook lambda
|
|
358
|
+
c.before_completion = nil # optional; global hook lambda
|
|
374
359
|
end
|
|
375
360
|
```
|
|
376
361
|
|
|
@@ -402,24 +387,6 @@ budget = Phronomy::Context::TokenBudget.new(
|
|
|
402
387
|
)
|
|
403
388
|
```
|
|
404
389
|
|
|
405
|
-
### Budget-aware Memory
|
|
406
|
-
|
|
407
|
-
Pass a budget to `load_messages` and only the newest messages that fit are returned:
|
|
408
|
-
|
|
409
|
-
```ruby
|
|
410
|
-
memory = Phronomy::Memory::WindowMemory.new
|
|
411
|
-
messages = memory.load_messages(thread_id: "t1", token_budget: budget)
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
`ActiveRecordMemory` also accepts `pruner:` to truncate oversized tool results:
|
|
415
|
-
|
|
416
|
-
```ruby
|
|
417
|
-
memory = Phronomy::Memory::ActiveRecordMemory.new(
|
|
418
|
-
model_class: PhronomyMessage,
|
|
419
|
-
pruner: Phronomy::Memory::Compression::ToolOutputPruner.new(max_chars: 4000)
|
|
420
|
-
)
|
|
421
|
-
```
|
|
422
|
-
|
|
423
390
|
### Agent DSL extensions
|
|
424
391
|
|
|
425
392
|
```ruby
|
|
@@ -430,59 +397,8 @@ class MyAgent < Phronomy::Agent::Base
|
|
|
430
397
|
end
|
|
431
398
|
```
|
|
432
399
|
|
|
433
|
-
`Agent::Base#invoke` builds a `TokenBudget` automatically
|
|
434
|
-
|
|
435
|
-
silently skipped.
|
|
436
|
-
|
|
437
|
-
### SemanticMemory
|
|
438
|
-
|
|
439
|
-
Embedding-based retrieval of relevant past messages:
|
|
440
|
-
|
|
441
|
-
```ruby
|
|
442
|
-
semantic = Phronomy::Memory::SemanticMemory.new(
|
|
443
|
-
embedding_model: "text-embedding-3-small",
|
|
444
|
-
k: 10
|
|
445
|
-
)
|
|
446
|
-
messages = semantic.load_messages(thread_id: "t1", query: "user's current question")
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
### Composite retrieval
|
|
450
|
-
|
|
451
|
-
Merge multiple retrieval strategies within a shared `ConversationManager`:
|
|
452
|
-
|
|
453
|
-
```ruby
|
|
454
|
-
composite_retrieval = Phronomy::Memory::Retrieval::Composite.new(
|
|
455
|
-
sources: [
|
|
456
|
-
{ retrieval: Phronomy::Memory::Retrieval::Recent.new(k: 5), weight: 0.4 },
|
|
457
|
-
{ retrieval: Phronomy::Memory::Retrieval::Semantic.new(k: 10), weight: 0.6 }
|
|
458
|
-
]
|
|
459
|
-
)
|
|
460
|
-
|
|
461
|
-
manager = Phronomy::Memory::ConversationManager.new(
|
|
462
|
-
storage: Phronomy::Memory::Storage::InMemory.new,
|
|
463
|
-
retrieval: composite_retrieval
|
|
464
|
-
)
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
### Memory Compression
|
|
468
|
-
|
|
469
|
-
Automatically shrink conversation history before it reaches the LLM.
|
|
470
|
-
|
|
471
|
-
```ruby
|
|
472
|
-
# Truncate oversized tool outputs (no LLM call, cheap)
|
|
473
|
-
pruner = Phronomy::Memory::Compression::ToolOutputPruner.new(max_chars: 4000)
|
|
474
|
-
|
|
475
|
-
# Summarise old messages when history exceeds max_tokens (calls summarizer_model)
|
|
476
|
-
summary = Phronomy::Memory::Compression::Summary.new(
|
|
477
|
-
max_tokens: 4000,
|
|
478
|
-
keep: 10, # always preserve the N most recent messages
|
|
479
|
-
summarizer_model: "gpt-4o-mini"
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
Phronomy.configure do |c|
|
|
483
|
-
c.memory_compression = [pruner, summary] # applied in order: pruner first, then summary
|
|
484
|
-
end
|
|
485
|
-
```
|
|
400
|
+
`Agent::Base#invoke` builds a `TokenBudget` automatically. When the model is not in the
|
|
401
|
+
registry the budget is silently skipped.
|
|
486
402
|
|
|
487
403
|
|
|
488
404
|
## Examples
|
|
@@ -512,7 +428,7 @@ bundle exec ruby NN_example_name/run.rb
|
|
|
512
428
|
| 12 | `12_prompt_template/` | Advanced prompt templates |
|
|
513
429
|
| 13 | `13_mcp_http_tool/` | HTTP-based MCP tool server |
|
|
514
430
|
| 14 | `14_code_review/` | Automated code review agent |
|
|
515
|
-
| 15 | `15_rails_secure_chat/` | Rails chat with PII guardrails
|
|
431
|
+
| 15 | `15_rails_secure_chat/` | Rails chat with PII guardrails |
|
|
516
432
|
| 16 | `16_before_completion_hook/` | Global/class/instance before_completion hooks |
|
|
517
433
|
| 17 | `17_multi_agent_handoff/` | Hub-and-spoke agent routing via Runner |
|
|
518
434
|
| 18 | `18_rails_agent_job/` | Rails app with AgentJob + ActionCable streaming |
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -402,18 +402,25 @@ module Phronomy
|
|
|
402
402
|
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
403
403
|
# variables consumed by the configured instructions template.
|
|
404
404
|
# @param config [Hash] runtime options:
|
|
405
|
-
# +:
|
|
405
|
+
# +:messages+ (Array<RubyLLM::Message>) — conversation history from a previous invocation
|
|
406
406
|
# +:thread_id+ (+String+) — conversation thread identifier
|
|
407
407
|
# +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
|
|
408
408
|
# +:session_id+ (+String+, optional) — session identity forwarded to the tracer
|
|
409
|
-
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }
|
|
409
|
+
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
410
|
+
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
411
|
+
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
410
412
|
# @raise [Phronomy::GuardrailError] when an input or output guardrail rejects the value
|
|
411
|
-
# @example
|
|
413
|
+
# @example Normal invocation
|
|
412
414
|
# result = MyAgent.new.invoke("What is Ruby?")
|
|
413
415
|
# puts result[:output]
|
|
416
|
+
# @example Suspend / resume flow
|
|
417
|
+
# result = agent.invoke("Perform task X")
|
|
418
|
+
# if result[:suspended]
|
|
419
|
+
# result = agent.resume(result[:checkpoint], approved: true)
|
|
420
|
+
# end
|
|
421
|
+
# puts result[:output]
|
|
414
422
|
def invoke(input, config: {})
|
|
415
|
-
|
|
416
|
-
_run_in_thread_actor(thread_id) { _invoke_impl(input, config: config) }
|
|
423
|
+
_invoke_impl(input, config: config)
|
|
417
424
|
end
|
|
418
425
|
|
|
419
426
|
# Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
|
|
@@ -433,23 +440,73 @@ module Phronomy
|
|
|
433
440
|
def stream(input, config: {}, &block)
|
|
434
441
|
return invoke(input, config: config) unless block
|
|
435
442
|
|
|
436
|
-
|
|
437
|
-
_run_in_thread_actor(thread_id) { _stream_impl(input, config: config, &block) }
|
|
443
|
+
_stream_impl(input, config: config, &block)
|
|
438
444
|
rescue => e
|
|
439
445
|
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
440
446
|
raise
|
|
441
447
|
end
|
|
442
448
|
|
|
449
|
+
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
450
|
+
#
|
|
451
|
+
# This method reconstructs the conversation state captured at suspension
|
|
452
|
+
# time, injects the tool result (executed or denied), and continues the
|
|
453
|
+
# LLM loop until it produces a final answer.
|
|
454
|
+
#
|
|
455
|
+
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
456
|
+
# the suspended #invoke call
|
|
457
|
+
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
458
|
+
# to inject a denial message and let the LLM handle it gracefully
|
|
459
|
+
# @param config [Hash] same runtime options as #invoke
|
|
460
|
+
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
461
|
+
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
462
|
+
def resume(checkpoint, approved:, config: {})
|
|
463
|
+
checkpoint.thread_id
|
|
464
|
+
|
|
465
|
+
# Build a fresh chat with all tools registered.
|
|
466
|
+
chat = build_chat
|
|
467
|
+
|
|
468
|
+
# Restore the full conversation (system + history + user + assistant).
|
|
469
|
+
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
470
|
+
|
|
471
|
+
# Determine the tool result: execute it or inject a denial string.
|
|
472
|
+
tool_result =
|
|
473
|
+
if approved
|
|
474
|
+
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
475
|
+
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
476
|
+
else
|
|
477
|
+
"Tool execution denied."
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Inject the tool result so the LLM can continue.
|
|
481
|
+
chat.add_message(
|
|
482
|
+
role: :tool,
|
|
483
|
+
content: tool_result.to_s,
|
|
484
|
+
tool_call_id: checkpoint.pending_tool_call_id
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Continue the React loop.
|
|
488
|
+
response = chat.complete
|
|
489
|
+
|
|
490
|
+
output = response.content
|
|
491
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
492
|
+
|
|
493
|
+
run_output_guardrails!(output)
|
|
494
|
+
|
|
495
|
+
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
496
|
+
end
|
|
497
|
+
|
|
443
498
|
# Registers a callback that is invoked before executing any tool that has
|
|
444
499
|
# +requires_approval true+ set. The block receives the tool name (String)
|
|
445
500
|
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
446
501
|
# Returning a falsy value causes the tool to return a denial message instead
|
|
447
502
|
# of executing.
|
|
448
503
|
#
|
|
449
|
-
# When no handler is registered
|
|
450
|
-
#
|
|
504
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
505
|
+
# called, #invoke returns a suspended result hash containing a
|
|
506
|
+
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
507
|
+
# obtaining an approval decision from the user or an external system.
|
|
451
508
|
#
|
|
452
|
-
# @example
|
|
509
|
+
# @example Synchronous handler
|
|
453
510
|
# agent = MyAgent.new
|
|
454
511
|
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
455
512
|
# @return [self]
|
|
@@ -510,7 +567,6 @@ module Phronomy
|
|
|
510
567
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
511
568
|
run_input_guardrails!(input)
|
|
512
569
|
|
|
513
|
-
memory = config[:memory]
|
|
514
570
|
thread_id = config[:thread_id]
|
|
515
571
|
|
|
516
572
|
chat = build_chat
|
|
@@ -528,8 +584,8 @@ module Phronomy
|
|
|
528
584
|
end
|
|
529
585
|
end
|
|
530
586
|
|
|
531
|
-
|
|
532
|
-
|
|
587
|
+
msgs = Array(config[:messages])
|
|
588
|
+
unless msgs.empty?
|
|
533
589
|
message_elements = build_message_elements(msgs)
|
|
534
590
|
|
|
535
591
|
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
@@ -547,8 +603,7 @@ module Phronomy
|
|
|
547
603
|
compact_ctx = Context::CompactionContext.new(
|
|
548
604
|
message_elements: message_elements,
|
|
549
605
|
budget: budget,
|
|
550
|
-
thread_id: thread_id
|
|
551
|
-
memory: memory
|
|
606
|
+
thread_id: thread_id
|
|
552
607
|
)
|
|
553
608
|
compact_cb.call(compact_ctx)
|
|
554
609
|
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
@@ -564,8 +619,18 @@ module Phronomy
|
|
|
564
619
|
context[:messages].each { |msg| chat.messages << msg }
|
|
565
620
|
|
|
566
621
|
# Wire per-event callbacks to yield StreamEvents.
|
|
567
|
-
|
|
568
|
-
chat.
|
|
622
|
+
current_tool_call = nil
|
|
623
|
+
chat.on_tool_call do |tool_call|
|
|
624
|
+
current_tool_call = tool_call
|
|
625
|
+
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
|
|
626
|
+
end
|
|
627
|
+
chat.on_tool_result do |tool_result|
|
|
628
|
+
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
629
|
+
tool_call_id: current_tool_call&.id,
|
|
630
|
+
tool_name: current_tool_call&.name,
|
|
631
|
+
tool_result: tool_result
|
|
632
|
+
}))
|
|
633
|
+
end
|
|
569
634
|
|
|
570
635
|
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
571
636
|
run_before_completion_hooks!(chat, config)
|
|
@@ -574,8 +639,6 @@ module Phronomy
|
|
|
574
639
|
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
575
640
|
end
|
|
576
641
|
|
|
577
|
-
save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
|
|
578
|
-
|
|
579
642
|
output = response.content
|
|
580
643
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
581
644
|
|
|
@@ -587,14 +650,6 @@ module Phronomy
|
|
|
587
650
|
end
|
|
588
651
|
end
|
|
589
652
|
|
|
590
|
-
# Runs +block+ inside the {Phronomy::ThreadActorRegistry} Actor for
|
|
591
|
-
# +thread_id+. When +thread_id+ is nil the block executes on the calling thread.
|
|
592
|
-
def _run_in_thread_actor(thread_id, &block)
|
|
593
|
-
return block.call unless thread_id
|
|
594
|
-
|
|
595
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
|
|
596
|
-
end
|
|
597
|
-
|
|
598
653
|
# Performs a single (non-retried) invocation. Extracted so that #invoke can
|
|
599
654
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
600
655
|
def invoke_once(input, config: {})
|
|
@@ -606,15 +661,13 @@ module Phronomy
|
|
|
606
661
|
# Run input guardrails before touching the LLM.
|
|
607
662
|
run_input_guardrails!(input)
|
|
608
663
|
|
|
609
|
-
memory = config[:memory]
|
|
610
664
|
thread_id = config[:thread_id]
|
|
611
665
|
user_message = extract_message(input)
|
|
612
666
|
chat = build_chat
|
|
613
667
|
budget = build_token_budget
|
|
614
668
|
|
|
615
|
-
# Load conversation history from
|
|
616
|
-
raw_messages = (
|
|
617
|
-
load_from_memory(memory, thread_id: thread_id, query: user_message) : []
|
|
669
|
+
# Load conversation history from config[:messages] (app-managed).
|
|
670
|
+
raw_messages = Array(config[:messages])
|
|
618
671
|
|
|
619
672
|
# Assign synthetic 0-based seq numbers for use by trim/compaction callbacks.
|
|
620
673
|
message_elements = build_message_elements(raw_messages)
|
|
@@ -636,8 +689,7 @@ module Phronomy
|
|
|
636
689
|
compact_ctx = Context::CompactionContext.new(
|
|
637
690
|
message_elements: message_elements,
|
|
638
691
|
budget: budget,
|
|
639
|
-
thread_id: thread_id
|
|
640
|
-
memory: memory
|
|
692
|
+
thread_id: thread_id
|
|
641
693
|
)
|
|
642
694
|
compact_cb.call(compact_ctx)
|
|
643
695
|
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
@@ -671,10 +723,23 @@ module Phronomy
|
|
|
671
723
|
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
672
724
|
run_before_completion_hooks!(chat, config)
|
|
673
725
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
726
|
+
# Register suspension hook for approval-required tools (no-op when a
|
|
727
|
+
# synchronous on_approval_required handler is already registered).
|
|
728
|
+
_register_suspension_hook!(chat)
|
|
729
|
+
|
|
730
|
+
begin
|
|
731
|
+
response = chat.ask(user_message)
|
|
732
|
+
rescue SuspendSignal => signal
|
|
733
|
+
checkpoint = Checkpoint.new(
|
|
734
|
+
thread_id: thread_id,
|
|
735
|
+
messages: chat.messages.dup,
|
|
736
|
+
pending_tool_name: signal.tool_name,
|
|
737
|
+
pending_tool_args: signal.args,
|
|
738
|
+
pending_tool_call_id: signal.tool_call_id
|
|
739
|
+
)
|
|
740
|
+
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
741
|
+
next [suspended_result, nil]
|
|
742
|
+
end
|
|
678
743
|
|
|
679
744
|
output = response.content
|
|
680
745
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
@@ -832,23 +897,6 @@ module Phronomy
|
|
|
832
897
|
|
|
833
898
|
# Load messages from a ConversationManager.
|
|
834
899
|
#
|
|
835
|
-
# @param memory [Memory::ConversationManager]
|
|
836
|
-
# @param thread_id [String]
|
|
837
|
-
# @param query [String, nil]
|
|
838
|
-
# @return [Array]
|
|
839
|
-
def load_from_memory(memory, thread_id:, query: nil)
|
|
840
|
-
memory.load(thread_id: thread_id, query: query)
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
# Persist messages to a ConversationManager.
|
|
844
|
-
#
|
|
845
|
-
# @param memory [Memory::ConversationManager]
|
|
846
|
-
# @param thread_id [String]
|
|
847
|
-
# @param messages [Array]
|
|
848
|
-
def save_to_memory(memory, thread_id:, messages:)
|
|
849
|
-
memory.save(thread_id: thread_id, messages: messages)
|
|
850
|
-
end
|
|
851
|
-
|
|
852
900
|
def build_chat
|
|
853
901
|
opts = {}
|
|
854
902
|
m = self.class.model
|
|
@@ -917,6 +965,31 @@ module Phronomy
|
|
|
917
965
|
(@output_guardrails || []).each { |g| g.run!(output) }
|
|
918
966
|
end
|
|
919
967
|
|
|
968
|
+
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
969
|
+
# when an approval-required tool is about to be executed and no synchronous
|
|
970
|
+
# on_approval_required handler has been registered.
|
|
971
|
+
#
|
|
972
|
+
# Does nothing when:
|
|
973
|
+
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
974
|
+
# - none of the agent's tools have requires_approval set.
|
|
975
|
+
#
|
|
976
|
+
# @param chat [RubyLLM::Chat]
|
|
977
|
+
def _register_suspension_hook!(chat)
|
|
978
|
+
return if @approval_handler
|
|
979
|
+
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
980
|
+
|
|
981
|
+
chat.on_tool_call do |tool_call|
|
|
982
|
+
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
983
|
+
if tool_instance&.requires_approval
|
|
984
|
+
raise SuspendSignal.new(
|
|
985
|
+
tool_name: tool_call.name,
|
|
986
|
+
args: tool_call.arguments,
|
|
987
|
+
tool_call_id: tool_call.id
|
|
988
|
+
)
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
920
993
|
# Builds the final tool class to register with the chat.
|
|
921
994
|
#
|
|
922
995
|
# Two transformations are applied in order:
|