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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -30
  3. data/README.md +26 -110
  4. data/lib/phronomy/agent/base.rb +127 -54
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/react_agent.rb +18 -28
  7. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  8. data/lib/phronomy/agent.rb +2 -1
  9. data/lib/phronomy/configuration.rb +0 -24
  10. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  11. data/lib/phronomy/railtie.rb +0 -6
  12. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  13. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  14. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  15. data/lib/phronomy/trust_pipeline.rb +1 -2
  16. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  17. data/lib/phronomy/version.rb +1 -1
  18. data/lib/phronomy/workflow.rb +4 -7
  19. data/lib/phronomy/workflow_runner.rb +1 -8
  20. data/lib/phronomy.rb +1 -0
  21. data/scripts/check_readme_ruby.rb +38 -0
  22. metadata +5 -33
  23. data/docs/trustworthy_ai_enhancements.md +0 -332
  24. data/lib/phronomy/active_record/acts_as.rb +0 -48
  25. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  26. data/lib/phronomy/active_record/extensions.rb +0 -14
  27. data/lib/phronomy/active_record/message.rb +0 -20
  28. data/lib/phronomy/actor.rb +0 -68
  29. data/lib/phronomy/memory/compression/base.rb +0 -37
  30. data/lib/phronomy/memory/compression/summary.rb +0 -107
  31. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  32. data/lib/phronomy/memory/compression.rb +0 -11
  33. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  34. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  35. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  36. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  37. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  38. data/lib/phronomy/memory/retrieval.rb +0 -12
  39. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  40. data/lib/phronomy/memory/storage/base.rb +0 -155
  41. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  42. data/lib/phronomy/memory/storage.rb +0 -11
  43. data/lib/phronomy/memory.rb +0 -21
  44. data/lib/phronomy/rails/agent_job.rb +0 -75
  45. data/lib/phronomy/state_store/active_record.rb +0 -76
  46. data/lib/phronomy/state_store/base.rb +0 -112
  47. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  48. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  49. data/lib/phronomy/state_store/encryptor.rb +0 -16
  50. data/lib/phronomy/state_store/file.rb +0 -85
  51. data/lib/phronomy/state_store/in_memory.rb +0 -53
  52. data/lib/phronomy/state_store/redis.rb +0 -70
  53. data/lib/phronomy/state_store.rb +0 -9
  54. data/lib/phronomy/thread_actor_registry.rb +0 -85
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e4dd11eb68b4f8d9f1eb9b75630a21424ee2d5c32035e2be9f9d8c34318feee
4
- data.tar.gz: ba85c6000643e00d6bc20298385728cc67df70b17f3542b439f516b4a23e92e5
3
+ metadata.gz: 9bb874213c4687c9021be3c78d8972218ed56980cfff777a624311ce476d7314
4
+ data.tar.gz: ab3017e56357b057943d31a557e9e1cd12555ec13924fbee92c6f0f7791c9bd1
5
5
  SHA512:
6
- metadata.gz: 1086a00d3ee1957b00954ac0a1ea70464c7e873e0cb873e23e53f27a38972d9a2bacca90204742c4c2c6cff90a3fa7972984ef2f16431187d5aa050ba4989ac3
7
- data.tar.gz: 8728503fb317f3ce9f05624d648e8080001cac7096898bd5ff2c95c736a040159a42d7ce26eebb28451afd367331d7a50fbe26ce37bf879d20a2de337247a76d
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
- As of 0.3.0, all state transition decisions — including guard evaluation for
65
- routing events — are delegated entirely to `state_machines`.
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 Memory — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
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 memory and guardrails | Stable |
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 | Stable |
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 an initializer and the required database migrations.
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, SSNs, emails, and phone numbers automatically
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
- ### RailsActiveRecord persistence
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
- class PhronomyMessage < ApplicationRecord
343
- acts_as_phronomy_message
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
- # config/initializers/phronomy.rb
347
- Phronomy.configure do |c|
348
- c.default_state_store = Phronomy::StateStore::ActiveRecord.new(
349
- model_class: PhronomyState # AR model backed by phronomy_states table
350
- )
351
- end
352
-
353
- # Use in a controller:
354
- agent = ResearchAgent.new
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.default_state_store = Phronomy::StateStore::InMemory.new # optional
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 and passes it to
434
- `memory.load_messages`. When the model is not in the registry the budget is
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 and secure memory |
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 |
@@ -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
- # +:memory+ ({Phronomy::Memory::ConversationManager}) memory backend
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
- thread_id = config[:thread_id]
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
- thread_id = config[:thread_id]
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, tools with +requires_approval+ execute
450
- # without interruption (backward-compatible behaviour).
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
- if memory && thread_id
532
- msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
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
- chat.before_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
568
- chat.after_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
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 memory.
616
- raw_messages = (memory && thread_id) ?
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
- response = chat.ask(user_message)
675
-
676
- # Persist the updated conversation to memory.
677
- save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
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: