phronomy 0.5.0 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 138e6b7d6b59f34f827e39a43b86c6f30ea0dd80e936d11e326febad4d3217b0
4
- data.tar.gz: fada502e034850a3162a488cb02fc195364fc93e72398e858a79058c005c2ad3
3
+ metadata.gz: f2129e4cbe20c3831530f0a8ef810abe272174195cbce5198207fec6bf255eff
4
+ data.tar.gz: f9a0152759518cb2d85126ee5fb7a57b96e00a0ed7b8acffd54c57de91ed0c18
5
5
  SHA512:
6
- metadata.gz: 55526d56e69e328f9de38e75da98a9a1e0d206997f3463a18aa0481f18d978896f02567a0fefcb6ec4fe2a5f030d3829dde59c479305f6e3ce9d825b06222ce8
7
- data.tar.gz: f58b275260866c5a7784c32c9846c9058cab815d6d294b92041dcf29525bbf76e1683c1151991c092eace65e5e55e41ad5390d6f6106f9306aa053bf42c5c0a8
6
+ metadata.gz: 0af940bc5279c64221d000e4c545c5e2696f2f14c405326785786b94cf9afc567619e94e79aefbfc68b0656d761ebe88b7604d1cbacf75e82ae3aca6d39b9e3f
7
+ data.tar.gz: 71d4ef2f73a4e914d4dd85fffef0643a1725857dc6bf51dce5c39e78f34a0bfdc69d2de59be574396175f3a2ea5841e502bde75d082ddb30f15ee30a1a598037
data/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.5.2] - 2026-05-20
11
+
12
+ ### Bug Fixes
13
+
14
+ - **CHANGELOG correction for v0.5.1 MCP fix** (#90): The v0.5.1 entry
15
+ incorrectly stated that a `Mutex` was added to `StdioTransport#rpc_call`.
16
+ The actual fix was per-instance transport ownership (each `McpTool` instance
17
+ creates its own transport in `initialize`). Corrected the description.
18
+
19
+ ### Enhancements
20
+
21
+ - **Add `McpTool#close`** (#92): Tool instances now expose a `close` method
22
+ that shuts down the underlying stdio child process (`StdioTransport`) or
23
+ releases the HTTP connection (`HttpTransport`). This gives callers a
24
+ deterministic way to clean up resources instead of relying on GC.
25
+
26
+ ### Maintenance
27
+
28
+ - **Archive stale Rails integration design doc** (#91): Added an archived
29
+ notice to `spec/design/17_rails_integration.md` clarifying that Rails
30
+ integration was removed in v0.3.0–v0.5.1 and the document is for
31
+ historical reference only.
32
+
33
+ - **Remove zombie `register_workflow_context` API** (#93): The
34
+ `Phronomy.register_workflow_context`, `workflow_context_registry`, and
35
+ `reset_workflow_context_registry!` methods (along with the backing
36
+ `@workflow_context_registry` and `@registry_mutex` module-level variables)
37
+ were removed from `lib/phronomy.rb`. These existed to support the
38
+ `StateStore` deserialization guard, which was removed in a prior release.
39
+ The API had no remaining callers in the codebase and was not listed in
40
+ the README stability table.
41
+
42
+ ---
43
+
44
+ ## [0.5.1] - 2026-05-21
45
+
46
+ ### Bug Fixes
47
+
48
+ - **Remove broken Rails generator and Railtie** (#85): The generator template
49
+ referenced `Phronomy::ActiveRecord::ActsAs` which no longer exists, causing
50
+ `rails generate phronomy:install` to produce broken model files. Removed
51
+ `lib/generators/`, `lib/phronomy/railtie.rb`, and all references in
52
+ `lib/phronomy.rb`.
53
+
54
+ - **Fix MCP transport ownership** (#86): `McpTool` no longer stores a shared
55
+ transport at class level. `from_server` now uses a short-lived transport only
56
+ to fetch tool metadata and calls `close` immediately after. Each tool instance
57
+ creates its own `StdioTransport` or `HttpTransport` in `initialize`, so
58
+ concurrent callers (e.g. via `Orchestrator#dispatch_parallel`) never share
59
+ stdio streams. No `Mutex` is needed. Also adds missing
60
+ `require "securerandom"` and a no-op `HttpTransport#close` for interface
61
+ consistency.
62
+
63
+ ### Documentation
64
+
65
+ - **README corrections** (#87): Remove stale Rails generator installation
66
+ instructions. Clarify that `TeamCoordinator` worker state is local to a
67
+ single `invoke` call (not persistent across calls). Annotate app-level
68
+ examples (`09_rails_chat`, `15_rails_secure_chat`, `18_rails_agent_job`,
69
+ `19_trust_pipeline`) as requiring external infrastructure. Add scope note
70
+ to `Agent::Orchestrator` section.
71
+
72
+ ### Maintenance
73
+
74
+ - **Add version guard to `ruby_llm_patches.rb`** (#88): The monkey-patch for
75
+ the upstream `handle_error_chunk` bug (ruby_llm <= 1.15.0) is now
76
+ gated behind a `Gem::Version` check so upgrading ruby_llm will
77
+ automatically disable the override.
78
+
79
+ ---
80
+
10
81
  ## [0.5.0] - 2026-05-20
11
82
 
12
83
  ### Breaking Changes
data/README.md CHANGED
@@ -20,7 +20,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
20
20
  | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
21
21
  | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
22
22
  | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
23
- | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + persistent worker pool with task queue | Beta |
23
+ | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful worker pool with task queue (worker-local message history per run) | Beta |
24
24
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
25
25
  | **Guardrails** — Input/output validation; built-in PII and prompt-injection detectors | Beta |
26
26
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
@@ -42,14 +42,6 @@ Then run:
42
42
  bundle install
43
43
  ```
44
44
 
45
- For Rails apps, run the install generator after bundling:
46
-
47
- ```bash
48
- rails generate phronomy:install
49
- ```
50
-
51
- This creates a configuration initializer.
52
-
53
45
  ## Quick Start
54
46
 
55
47
  ### Agent — ReAct tool-calling agent
@@ -279,6 +271,11 @@ end
279
271
 
280
272
  ### Agent::Orchestrator — Parallel subagent dispatch
281
273
 
274
+ > **Note:** `dispatch_parallel` and `fan_out` use plain Ruby threads and are
275
+ > intended for small-scale fan-out (a handful of subagents). For large-scale
276
+ > parallel dispatch, manage concurrency (thread pools, rate limiting) at the
277
+ > application level.
278
+
282
279
  ```ruby
283
280
  class ResearchOrchestrator < Phronomy::Agent::Orchestrator
284
281
  model "gpt-4o"
@@ -394,6 +391,13 @@ search_tool = Phronomy::Tool::McpTool.from_server(
394
391
  )
395
392
  ```
396
393
 
394
+ Call `close` when the tool is no longer needed to shut down the underlying
395
+ child process (stdio transport) or release the HTTP connection:
396
+
397
+ ```ruby
398
+ search_tool.close
399
+ ```
400
+
397
401
  ### Conversation History — passing prior messages
398
402
 
399
403
  Phronomy does not manage conversation history internally. The application owns the
@@ -491,15 +495,21 @@ bundle exec ruby NN_example_name/run.rb
491
495
  | 06 | `06_guardrails/` | Input/output guardrails |
492
496
  | 07 | `07_tracing/` | Custom observability with Langfuse tracer |
493
497
  | 08 | `08_mcp_tool/` | MCP tool integration |
494
- | 09 | `09_rails_chat/` | Rails chat app with ActionCable streaming |
495
498
  | 10 | `10_context_management/` | Token budget and context pruning |
496
499
  | 11 | `11_agent_streaming/` | Streaming agent responses |
497
500
  | 12 | `12_prompt_template/` | Advanced prompt templates |
498
501
  | 13 | `13_mcp_http_tool/` | HTTP-based MCP tool server |
499
502
  | 14 | `14_code_review/` | Automated code review agent |
500
- | 15 | `15_rails_secure_chat/` | Rails chat with PII guardrails |
501
503
  | 16 | `16_before_completion_hook/` | Global/class/instance before_completion hooks |
502
504
  | 17 | `17_multi_agent_handoff/` | Hub-and-spoke agent routing via Runner |
505
+
506
+ The following examples are **app-level demos** (Rails apps or advanced pipelines)
507
+ that require additional infrastructure (a running Rails server, database, etc.):
508
+
509
+ | # | Directory | What it demonstrates |
510
+ |---|-----------|----------------------|
511
+ | 09 | `09_rails_chat/` | Rails chat app with ActionCable streaming |
512
+ | 15 | `15_rails_secure_chat/` | Rails chat with PII guardrails |
503
513
  | 18 | `18_rails_agent_job/` | Rails app with AgentJob + ActionCable streaming |
504
514
  | 19 | `19_trust_pipeline/` | Generator-Verifier pattern with citation tracking, self-review loop and confidence gate |
505
515
 
@@ -3,18 +3,22 @@
3
3
  # Patches for upstream ruby_llm bugs that have not yet been released.
4
4
  # Remove each patch once the fix is available in a published gem version.
5
5
 
6
- module RubyLLM
7
- module Streaming
8
- private
6
+ # Guard: apply monkey-patches only to affected ruby_llm versions so that
7
+ # upgrading the gem does not silently keep dead overrides in place.
8
+ if Gem::Version.new(RubyLLM::VERSION) <= Gem::Version.new("1.15.0")
9
+ module RubyLLM
10
+ module Streaming
11
+ private
9
12
 
10
- # Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
11
- # lines ("event: error\ndata: {...}") and uses a fixed index [1], which
12
- # raises NoMethodError when some providers (e.g. Qwen) return a single-line
13
- # chunk ("data: {...}"). This patch finds the data line by content instead.
14
- def handle_error_chunk(chunk, env)
15
- data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
16
- error_data = data_line.delete_prefix("data: ")
17
- parse_error_from_json(error_data, env, "Failed to parse error chunk")
13
+ # Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
14
+ # lines ("event: error\ndata: {...}") and uses a fixed index [1], which
15
+ # raises NoMethodError when some providers (e.g. Qwen) return a single-line
16
+ # chunk ("data: {...}"). This patch finds the data line by content instead.
17
+ def handle_error_chunk(chunk, env)
18
+ data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
19
+ error_data = data_line.delete_prefix("data: ")
20
+ parse_error_from_json(error_data, env, "Failed to parse error chunk")
21
+ end
18
22
  end
19
23
  end
20
24
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "net/http"
5
5
  require "open3"
6
+ require "securerandom"
6
7
  require "shellwords"
7
8
  require "uri"
8
9
 
@@ -36,9 +37,13 @@ module Phronomy
36
37
  # @param tool_name [String] the tool name as registered in the MCP server
37
38
  # @return [McpTool] a configured subclass instance ready for use with an Agent
38
39
  def from_server(server_uri, tool_name:)
40
+ # Use a short-lived transport only to query the tool definition,
41
+ # then close it. Each McpTool instance creates its own transport
42
+ # so that concurrent callers never share IO streams.
39
43
  transport = build_transport(server_uri)
40
44
  tool_def = transport.fetch_tool(tool_name)
41
- build_tool_class(tool_name, tool_def, transport).new
45
+ transport.close
46
+ build_tool_class(tool_name, server_uri, tool_def).new
42
47
  end
43
48
 
44
49
  private
@@ -55,10 +60,10 @@ module Phronomy
55
60
  end
56
61
  end
57
62
 
58
- def build_tool_class(tool_name, tool_def, transport)
63
+ def build_tool_class(tool_name, server_uri, tool_def)
59
64
  klass = Class.new(McpTool)
60
65
  klass.instance_variable_set(:@mcp_tool_name, tool_name)
61
- klass.instance_variable_set(:@mcp_transport, transport)
66
+ klass.instance_variable_set(:@mcp_server_uri, server_uri)
62
67
 
63
68
  # Register description and params from the MCP tool definition.
64
69
  klass.description(tool_def[:description] || tool_name)
@@ -66,10 +71,22 @@ module Phronomy
66
71
  klass.param(p[:name].to_sym, type: p[:type]&.to_sym || :string, desc: p[:description].to_s)
67
72
  end
68
73
 
69
- # Define #execute to forward the call to the MCP server.
74
+ # Each instance creates its own transport so concurrent agent threads
75
+ # never share IO streams, eliminating the need for synchronisation.
76
+ klass.define_method(:initialize) do
77
+ uri = self.class.instance_variable_get(:@mcp_server_uri)
78
+ @mcp_transport = self.class.send(:build_transport, uri)
79
+ end
80
+
70
81
  klass.define_method(:execute) do |**args|
71
- self.class.instance_variable_get(:@mcp_transport)
72
- .call_tool(tool_name, args)
82
+ @mcp_transport.call_tool(tool_name, args)
83
+ end
84
+
85
+ # Allow callers to deterministically shut down the underlying child
86
+ # process (stdio) or release the HTTP connection. For HttpTransport
87
+ # this is a no-op. Calling execute after close raises an error.
88
+ klass.define_method(:close) do
89
+ @mcp_transport.close
73
90
  end
74
91
 
75
92
  klass
@@ -108,7 +125,6 @@ module Phronomy
108
125
  wait_thr = @wait_thr
109
126
  @stderr_thread = nil
110
127
  @wait_thr = nil
111
- # Join outside the lock to avoid blocking on slow joins.
112
128
  stderr_thread&.join(1)
113
129
  wait_thr&.join(5)
114
130
  end
@@ -208,6 +224,11 @@ module Phronomy
208
224
  @read_timeout = read_timeout
209
225
  end
210
226
 
227
+ # HTTP connections are stateless; close is a no-op, defined so that
228
+ # both transport classes share the same interface as StdioTransport.
229
+ def close
230
+ end
231
+
211
232
  # Retrieve the tool definition from the server using MCP `tools/list`.
212
233
  # @param tool_name [String]
213
234
  # @return [Hash] { description:, parameters: }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -5,7 +5,6 @@ require "ruby_llm"
5
5
  require_relative "phronomy/ruby_llm_patches"
6
6
 
7
7
  loader = Zeitwerk::Loader.for_gem
8
- loader.ignore(File.expand_path("generators", __dir__))
9
8
  # Teach Zeitwerk that "llm" maps to "LLM" so that file names such as
10
9
  # ruby_llm_embeddings.rb resolve to RubyLLMEmbeddings (not RubyLlmEmbeddings).
11
10
  loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
@@ -14,8 +13,6 @@ loader.setup
14
13
  require_relative "phronomy/version"
15
14
  require_relative "phronomy/token_usage"
16
15
 
17
- require "phronomy/railtie" if defined?(Rails::Railtie)
18
-
19
16
  module Phronomy
20
17
  # Exception hierarchy
21
18
  class Error < StandardError; end
@@ -53,42 +50,7 @@ module Phronomy
53
50
  end
54
51
  end
55
52
 
56
- # Registry for WorkflowContext classes that may be serialized to external stores
57
- # (Redis, DB). Call +register_workflow_context+ at application startup so that
58
- # only known classes can be deserialized.
59
- @workflow_context_registry = nil
60
- @registry_mutex = Mutex.new
61
-
62
53
  class << self
63
- # Register one or more WorkflowContext classes that are allowed to be
64
- # deserialized by StateStore backends. When at least one class is registered,
65
- # only registered classes will be accepted by
66
- # +StateStore::Base#safe_state_class+.
67
- #
68
- # Call this once at application startup (e.g. in a Rails initializer).
69
- #
70
- # @param classes [Array<Class>] classes including Phronomy::WorkflowContext
71
- # @example
72
- # Phronomy.register_workflow_context(ScanContext, OtherContext)
73
- def register_workflow_context(*classes)
74
- @registry_mutex.synchronize do
75
- @workflow_context_registry ||= {}
76
- classes.each do |klass|
77
- raise ArgumentError, "#{klass.inspect} is not a Class" unless klass.is_a?(Class)
78
- @workflow_context_registry[klass.name] = klass
79
- end
80
- end
81
- end
82
-
83
- # Returns the current registry Hash, or nil when no class has been registered.
84
- # @return [Hash{String => Class}, nil]
85
- attr_reader :workflow_context_registry
86
-
87
- # Clears the registry. Primarily used in tests.
88
- def reset_workflow_context_registry!
89
- @registry_mutex.synchronize { @workflow_context_registry = nil }
90
- end
91
-
92
54
  def configuration
93
55
  @configuration ||= Configuration.new
94
56
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -64,10 +64,6 @@ files:
64
64
  - CHANGELOG.md
65
65
  - README.md
66
66
  - Rakefile
67
- - lib/generators/phronomy/install/install_generator.rb
68
- - lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt
69
- - lib/generators/phronomy/install/templates/initializer.rb.tt
70
- - lib/generators/phronomy/install/templates/message_model.rb.tt
71
67
  - lib/phronomy.rb
72
68
  - lib/phronomy/agent.rb
73
69
  - lib/phronomy/agent/base.rb
@@ -132,7 +128,6 @@ files:
132
128
  - lib/phronomy/output_parser/json_parser.rb
133
129
  - lib/phronomy/output_parser/structured_parser.rb
134
130
  - lib/phronomy/prompt_template.rb
135
- - lib/phronomy/railtie.rb
136
131
  - lib/phronomy/ruby_llm_patches.rb
137
132
  - lib/phronomy/runnable.rb
138
133
  - lib/phronomy/splitter.rb
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module Phronomy
7
- module Generators
8
- # Rails generator that installs Phronomy into a Rails app.
9
- # Creates an initializer and database migrations:
10
- # - phronomy_messages (conversation history persistence)
11
- #
12
- # Usage:
13
- # rails generate phronomy:install
14
- class InstallGenerator < ::Rails::Generators::Base
15
- include ::Rails::Generators::Migration
16
-
17
- source_root File.expand_path("templates", __dir__)
18
-
19
- desc "Creates a Phronomy initializer and database migrations."
20
-
21
- def self.next_migration_number(dirname)
22
- ::ActiveRecord::Generators::Base.next_migration_number(dirname)
23
- end
24
-
25
- def copy_initializer
26
- template "initializer.rb.tt", "config/initializers/phronomy.rb"
27
- end
28
-
29
- def create_messages_migration
30
- migration_template(
31
- "create_phronomy_messages.rb.tt",
32
- "db/migrate/create_phronomy_messages.rb"
33
- )
34
- end
35
-
36
- def create_message_model
37
- template "message_model.rb.tt", "app/models/phronomy_message.rb"
38
- end
39
- end
40
- end
41
- end
@@ -1,15 +0,0 @@
1
- class CreatePhronomyMessages < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
- def change
3
- create_table :phronomy_messages do |t|
4
- t.string :thread_id, null: false
5
- t.string :role, null: false
6
- t.text :content
7
- t.text :tool_calls_json
8
- t.string :model_id
9
- t.timestamps
10
- end
11
-
12
- add_index :phronomy_messages, :thread_id
13
- add_index :phronomy_messages, [:thread_id, :created_at]
14
- end
15
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Phronomy configuration initializer.
4
- # Customize LLM settings and agent defaults here.
5
- Phronomy.configure do |config|
6
- # Default LLM model used when no model is specified on an agent or chain.
7
- # config.default_model = "gpt-4o-mini"
8
-
9
- # Maximum graph recursion depth (node steps per invoke).
10
- # config.recursion_limit = 25
11
- end
12
-
13
- # RubyLLM provider credentials.
14
- # Prefer Rails credentials (config/credentials.yml.enc) over ENV vars.
15
- RubyLLM.configure do |c|
16
- c.openai_api_key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
17
- c.anthropic_api_key = Rails.application.credentials.dig(:anthropic, :api_key) || ENV["ANTHROPIC_API_KEY"]
18
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Generated by `rails generate phronomy:install`.
4
- # Stores conversation messages keyed by thread_id.
5
- class PhronomyMessage < ApplicationRecord
6
- include Phronomy::ActiveRecord::ActsAs
7
- acts_as_phronomy_message
8
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Railtie providing Rails integration for Phronomy.
5
- # Loaded only when Rails is present.
6
- class Railtie < ::Rails::Railtie
7
- # Registers generator paths (rails generate phronomy:install).
8
- generators do
9
- require "generators/phronomy/install/install_generator"
10
- end
11
-
12
- # Fills in defaults not already set by a phronomy.rb initializer.
13
- initializer "phronomy.configure_defaults" do
14
- # Passes LLM API keys from Rails credentials to RubyLLM (only when present).
15
- # Use ::Rails to avoid resolving to Phronomy::Rails by accident.
16
- if ::Rails.application.credentials.respond_to?(:openai_api_key) &&
17
- ::Rails.application.credentials.openai_api_key
18
- RubyLLM.configure do |c|
19
- c.openai_api_key = ::Rails.application.credentials.openai_api_key
20
- end
21
- end
22
-
23
- if ::Rails.application.credentials.respond_to?(:anthropic_api_key) &&
24
- ::Rails.application.credentials.anthropic_api_key
25
- RubyLLM.configure do |c|
26
- c.anthropic_api_key = ::Rails.application.credentials.anthropic_api_key
27
- end
28
- end
29
- end
30
-
31
- # Loads Phronomy::Rails::AgentJob when both ActionCable and ActiveJob are present.
32
- initializer "phronomy.agent_job" do
33
- end
34
-
35
- # Loads Phronomy ActiveRecord extensions when ActiveRecord is available.
36
- initializer "phronomy.active_record", after: "active_record.initialize_database" do
37
- end
38
- end
39
- end