agentf 0.4.6 → 0.5.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.
@@ -3,9 +3,9 @@
3
3
  begin
4
4
  require "mcp"
5
5
  rescue LoadError
6
- require_relative "stub"
7
6
  end
8
7
  require "json"
8
+ require "set"
9
9
 
10
10
  module Agentf
11
11
  module MCP
@@ -20,6 +20,161 @@ module Agentf
20
20
  # AGENTF_MCP_ALLOW_WRITES - true/false, controls memory write tools
21
21
  # AGENTF_MCP_MAX_ARG_LENGTH - max length per string argument
22
22
  class Server
23
+ ToolDefinition = Struct.new(:name, :description, :arguments, :handler, keyword_init: true)
24
+
25
+ class ToolBuilder
26
+ attr_reader :arguments, :handler
27
+
28
+ def initialize(name)
29
+ @name = name
30
+ @arguments = {}
31
+ end
32
+
33
+ def description(value = nil)
34
+ @description = value unless value.nil?
35
+ @description
36
+ end
37
+
38
+ def argument(name, type, required: false, description:, items: nil, **_opts)
39
+ @arguments[name] = {
40
+ type: type,
41
+ items: items,
42
+ required: required,
43
+ schema: {
44
+ type: schema_type(type),
45
+ description: description
46
+ }.tap do |schema|
47
+ schema[:items] = { type: schema_type(items) } if type == Array && items
48
+ end
49
+ }
50
+ end
51
+
52
+ def call(&block)
53
+ @handler = block
54
+ end
55
+
56
+ private
57
+
58
+ def schema_type(type)
59
+ case type&.name
60
+ when "String" then "string"
61
+ when "Integer" then "integer"
62
+ when "Float" then "number"
63
+ when "TrueClass", "FalseClass" then "boolean"
64
+ when "Array" then "array"
65
+ when "Hash" then "object"
66
+ else
67
+ "string"
68
+ end
69
+ end
70
+ end
71
+
72
+ class RegistryAdapter
73
+ def initialize(name:, version:)
74
+ @name = name
75
+ @version = version
76
+ @tools = {}
77
+ end
78
+
79
+ def tool(name, &block)
80
+ builder = ToolBuilder.new(name)
81
+ builder.instance_eval(&block)
82
+ @tools[name] = ToolDefinition.new(
83
+ name: name,
84
+ description: builder.description,
85
+ arguments: builder.arguments,
86
+ handler: builder.handler
87
+ )
88
+ end
89
+
90
+ def list_tools
91
+ @tools.values.map do |tool|
92
+ {
93
+ name: tool.name,
94
+ description: tool.description.to_s,
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: tool.arguments.transform_values { |arg| arg[:schema] },
98
+ required: tool.arguments.select { |_k, arg| arg[:required] }.keys.map(&:to_s)
99
+ }
100
+ }
101
+ end
102
+ end
103
+
104
+ def call_tool(name, **args)
105
+ tool = @tools[name]
106
+ raise "Unknown tool: #{name}" unless tool
107
+
108
+ tool.handler.call(args)
109
+ rescue StandardError => e
110
+ e.message
111
+ end
112
+
113
+ def run
114
+ runtime_server = build_runtime_server
115
+ transport = ::MCP::Server::Transports::StdioTransport.new(runtime_server)
116
+ runtime_server.transport = transport if runtime_server.respond_to?(:transport=)
117
+ transport.open
118
+ end
119
+
120
+ private
121
+
122
+ def build_runtime_server
123
+ ensure_runtime_support!
124
+
125
+ runtime_server = ::MCP::Server.new(name: @name, version: @version)
126
+ adapter = self
127
+ @tools.each_value do |tool|
128
+ runtime_server.define_tool(
129
+ name: tool.name,
130
+ description: tool.description,
131
+ input_schema: runtime_input_schema(tool.arguments)
132
+ ) do |**kwargs|
133
+ result = tool.handler.call(kwargs)
134
+ adapter.send(:build_response, result)
135
+ rescue StandardError => e
136
+ ::MCP::Tool::Response.new([::MCP::Content::Text.new(e.message).to_h], error: true)
137
+ end
138
+ end
139
+ runtime_server
140
+ end
141
+
142
+ def ensure_runtime_support!
143
+ return if defined?(::MCP::Server::Transports::StdioTransport) && defined?(::MCP::Tool::Response) && defined?(::MCP::Content::Text)
144
+
145
+ raise "Installed MCP runtime does not support stdio server transport"
146
+ end
147
+
148
+ def runtime_input_schema(arguments)
149
+ schema = {
150
+ type: "object",
151
+ properties: arguments.transform_keys(&:to_s).transform_values { |arg| arg[:schema].transform_keys(&:to_s) }
152
+ }
153
+
154
+ required = arguments.select { |_k, arg| arg[:required] }.keys.map(&:to_s)
155
+ schema[:required] = required unless required.empty?
156
+ schema
157
+ end
158
+
159
+ def build_response(result)
160
+ return result if result.is_a?(::MCP::Tool::Response)
161
+
162
+ text = result.is_a?(String) ? result : JSON.generate(result)
163
+ structured = parse_structured_content(result)
164
+ ::MCP::Tool::Response.new([::MCP::Content::Text.new(text).to_h], structured_content: structured)
165
+ end
166
+
167
+ def parse_structured_content(result)
168
+ return result if result.is_a?(Hash)
169
+ return JSON.parse(result) if result.is_a?(String)
170
+ return result.as_json if result.respond_to?(:as_json)
171
+
172
+ nil
173
+ rescue JSON::ParserError
174
+ nil
175
+ end
176
+ end
177
+
23
178
  KNOWN_TOOLS = %w[
24
179
  agentf-code-glob
25
180
  agentf-code-grep
@@ -66,6 +221,25 @@ module Agentf
66
221
  @server = build_server
67
222
  end
68
223
 
224
+ # Helper to centralize confirmation handling for MCP server write tools.
225
+ # Yields the block that performs the memory write and returns either the
226
+ # block result or the normalized confirmation hash produced by
227
+ # Agentf::Agents::Base#safe_memory_write.
228
+ def safe_mcp_memory_write(memory, attempted: {})
229
+ begin
230
+ yield
231
+ nil
232
+ rescue Agentf::Memory::RedisMemory::ConfirmationRequired => e
233
+ {
234
+ "confirmation_required" => true,
235
+ "confirmation_details" => e.details,
236
+ "attempted" => attempted,
237
+ "confirmed_write_token" => "confirmed",
238
+ "confirmation_prompt" => "Ask the user whether to save this memory. If they approve, call the same tool again after confirmation. If they decline, do not retry."
239
+ }
240
+ end
241
+ end
242
+
69
243
  # Start the stdio read loop (blocks until stdin closes).
70
244
  def run
71
245
  @server.run
@@ -139,7 +313,7 @@ module Agentf
139
313
  # ── Server construction ─────────────────────────────────────
140
314
 
141
315
  def build_server
142
- s = ::MCP::Server.new(name: "agentf", version: Agentf::VERSION)
316
+ s = RegistryAdapter.new(name: "agentf", version: Agentf::VERSION)
143
317
  register_code_tools(s)
144
318
  register_memory_tools(s)
145
319
  register_architecture_tools(s)
@@ -349,14 +523,24 @@ module Agentf
349
523
  argument :priority, Integer, required: false, description: "Priority"
350
524
  call do |args|
351
525
  mcp_server.send(:guard!, "agentf-memory-add-business-intent", **args)
352
- id = memory.store_business_intent(
353
- title: args[:title],
354
- description: args[:description],
355
- tags: args[:tags] || [],
356
- constraints: args[:constraints] || [],
357
- priority: args[:priority] || 1
358
- )
359
- JSON.generate(id: id, type: "business_intent", status: "stored")
526
+ begin
527
+ id = nil
528
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-business-intent", args: args }) do
529
+ id = memory.store_business_intent(
530
+ title: args[:title],
531
+ description: args[:description],
532
+ tags: args[:tags] || [],
533
+ constraints: args[:constraints] || [],
534
+ priority: args[:priority] || 1
535
+ )
536
+ end
537
+
538
+ if res.is_a?(Hash) && res["confirmation_required"]
539
+ JSON.generate(confirmation_required: true, confirmation_details: res["confirmation_details"], attempted: res["attempted"])
540
+ else
541
+ JSON.generate(id: id, type: "business_intent", status: "stored")
542
+ end
543
+ end
360
544
  end
361
545
  end
362
546
 
@@ -370,15 +554,25 @@ module Agentf
370
554
  argument :related_task_id, String, required: false, description: "Related task id"
371
555
  call do |args|
372
556
  mcp_server.send(:guard!, "agentf-memory-add-feature-intent", **args)
373
- id = memory.store_feature_intent(
374
- title: args[:title],
375
- description: args[:description],
376
- tags: args[:tags] || [],
377
- acceptance_criteria: args[:acceptance] || [],
378
- non_goals: args[:non_goals] || [],
379
- related_task_id: args[:related_task_id]
380
- )
381
- JSON.generate(id: id, type: "feature_intent", status: "stored")
557
+ begin
558
+ id = nil
559
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-feature-intent", args: args }) do
560
+ id = memory.store_feature_intent(
561
+ title: args[:title],
562
+ description: args[:description],
563
+ tags: args[:tags] || [],
564
+ acceptance_criteria: args[:acceptance] || [],
565
+ non_goals: args[:non_goals] || [],
566
+ related_task_id: args[:related_task_id]
567
+ )
568
+ end
569
+
570
+ if res.is_a?(Hash) && res["confirmation_required"]
571
+ JSON.generate(confirmation_required: true, confirmation_details: res["confirmation_details"], attempted: res["attempted"])
572
+ else
573
+ JSON.generate(id: id, type: "feature_intent", status: "stored")
574
+ end
575
+ end
382
576
  end
383
577
  end
384
578
 
@@ -427,16 +621,32 @@ module Agentf
427
621
  argument :context, String, required: false, description: "Context"
428
622
  call do |args|
429
623
  mcp_server.send(:guard!, "agentf-memory-add-lesson", **args)
430
- id = memory.store_episode(
431
- type: "lesson",
432
- title: args[:title],
433
- description: args[:description],
434
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
435
- tags: args[:tags] || [],
436
- context: args[:context].to_s,
437
- code_snippet: ""
438
- )
439
- JSON.generate(id: id, type: "lesson", status: "stored")
624
+ begin
625
+ id = nil
626
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-lesson", args: args }) do
627
+ id = memory.store_episode(
628
+ type: "lesson",
629
+ title: args[:title],
630
+ description: args[:description],
631
+ agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
632
+ tags: args[:tags] || [],
633
+ context: args[:context].to_s,
634
+ code_snippet: ""
635
+ )
636
+ end
637
+
638
+ if res.is_a?(Hash) && res["confirmation_required"]
639
+ JSON.generate(
640
+ confirmation_required: true,
641
+ confirmation_details: res["confirmation_details"],
642
+ attempted: res["attempted"],
643
+ confirmed_write_token: res["confirmed_write_token"],
644
+ confirmation_prompt: res["confirmation_prompt"]
645
+ )
646
+ else
647
+ JSON.generate(id: id, type: "lesson", status: "stored")
648
+ end
649
+ end
440
650
  end
441
651
  end
442
652
 
@@ -449,16 +659,32 @@ module Agentf
449
659
  argument :context, String, required: false, description: "Context"
450
660
  call do |args|
451
661
  mcp_server.send(:guard!, "agentf-memory-add-success", **args)
452
- id = memory.store_episode(
453
- type: "success",
454
- title: args[:title],
455
- description: args[:description],
456
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
457
- tags: args[:tags] || [],
458
- context: args[:context].to_s,
459
- code_snippet: ""
460
- )
461
- JSON.generate(id: id, type: "success", status: "stored")
662
+ begin
663
+ id = nil
664
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-success", args: args }) do
665
+ id = memory.store_episode(
666
+ type: "success",
667
+ title: args[:title],
668
+ description: args[:description],
669
+ agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
670
+ tags: args[:tags] || [],
671
+ context: args[:context].to_s,
672
+ code_snippet: ""
673
+ )
674
+ end
675
+
676
+ if res.is_a?(Hash) && res["confirmation_required"]
677
+ JSON.generate(
678
+ confirmation_required: true,
679
+ confirmation_details: res["confirmation_details"],
680
+ attempted: res["attempted"],
681
+ confirmed_write_token: res["confirmed_write_token"],
682
+ confirmation_prompt: res["confirmation_prompt"]
683
+ )
684
+ else
685
+ JSON.generate(id: id, type: "success", status: "stored")
686
+ end
687
+ end
462
688
  end
463
689
  end
464
690
 
@@ -471,16 +697,32 @@ module Agentf
471
697
  argument :context, String, required: false, description: "Context"
472
698
  call do |args|
473
699
  mcp_server.send(:guard!, "agentf-memory-add-pitfall", **args)
474
- id = memory.store_episode(
475
- type: "pitfall",
476
- title: args[:title],
477
- description: args[:description],
478
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
479
- tags: args[:tags] || [],
480
- context: args[:context].to_s,
481
- code_snippet: ""
482
- )
483
- JSON.generate(id: id, type: "pitfall", status: "stored")
700
+ begin
701
+ id = nil
702
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-pitfall", args: args }) do
703
+ id = memory.store_episode(
704
+ type: "pitfall",
705
+ title: args[:title],
706
+ description: args[:description],
707
+ agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
708
+ tags: args[:tags] || [],
709
+ context: args[:context].to_s,
710
+ code_snippet: ""
711
+ )
712
+ end
713
+
714
+ if res.is_a?(Hash) && res["confirmation_required"]
715
+ JSON.generate(
716
+ confirmation_required: true,
717
+ confirmation_details: res["confirmation_details"],
718
+ attempted: res["attempted"],
719
+ confirmed_write_token: res["confirmed_write_token"],
720
+ confirmation_prompt: res["confirmation_prompt"]
721
+ )
722
+ else
723
+ JSON.generate(id: id, type: "pitfall", status: "stored")
724
+ end
725
+ end
484
726
  end
485
727
  end
486
728
  end
data/lib/agentf/memory.rb CHANGED
@@ -23,6 +23,16 @@ module Agentf
23
23
  ensure_indexes if @search_supported
24
24
  end
25
25
 
26
+ # Raised when a write requires explicit user confirmation (ask_first).
27
+ class ConfirmationRequired < StandardError
28
+ attr_reader :details
29
+
30
+ def initialize(message = "confirmation required to persist memory", details = {})
31
+ super(message)
32
+ @details = details
33
+ end
34
+ end
35
+
26
36
  def store_task(content:, embedding: [], language: nil, task_type: nil, success: true, agent: Agentf::AgentRoles::PLANNER)
27
37
  task_id = "task_#{SecureRandom.hex(4)}"
28
38
 
@@ -44,8 +54,34 @@ module Agentf
44
54
  task_id
45
55
  end
46
56
 
47
- def store_episode(type:, title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER,
48
- related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil)
57
+ def store_episode(type:, title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR,
58
+ related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil, confirm: nil)
59
+ # Determine persistence preference from the agent's policy boundaries.
60
+ # Precedence: never > ask_first > always > none.
61
+ # For local/dev testing we may bypass interactive confirmation when
62
+ # AGENTF_AUTO_CONFIRM_MEMORIES=true. Otherwise, when an agent declares
63
+ # an "ask_first" persistence preference we raise ConfirmationRequired
64
+ # so higher-level code (MCP server / workflow engine / CLI) can prompt
65
+ # the user and retry the write with confirm: true.
66
+ auto_confirm = ENV['AGENTF_AUTO_CONFIRM_MEMORIES'] == 'true'
67
+ pref = persistence_preference_for(agent)
68
+
69
+ case pref
70
+ when :never
71
+ begin
72
+ puts "[MEMORY] Skipping persistence for #{agent}: policy forbids persisting memories"
73
+ rescue StandardError
74
+ end
75
+ return nil
76
+ when :ask_first
77
+ # If the agent's policy requires asking first, and we do not have
78
+ # an explicit confirmation (confirm: true) and auto_confirm is not
79
+ # enabled, raise ConfirmationRequired so callers can handle prompting.
80
+ unless auto_confirm || confirm == true
81
+ details = { "reason" => "ask_first", "agent" => agent.to_s, "attempted" => { "type" => type, "title" => title } }
82
+ raise ConfirmationRequired.new("confirm", details)
83
+ end
84
+ end
49
85
  episode_id = "episode_#{SecureRandom.hex(4)}"
50
86
  normalized_metadata = enrich_metadata(
51
87
  metadata: metadata,
@@ -107,52 +143,56 @@ module Agentf
107
143
  episode_id
108
144
  end
109
145
 
110
- def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER)
111
- store_episode(
146
+ def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
147
+ store_episode(
112
148
  type: "success",
113
149
  title: title,
114
150
  description: description,
115
151
  context: context,
116
152
  code_snippet: code_snippet,
117
153
  tags: tags,
118
- agent: agent
154
+ agent: agent,
155
+ confirm: confirm
119
156
  )
120
157
  end
121
158
 
122
- def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER)
123
- store_episode(
159
+ def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
160
+ store_episode(
124
161
  type: "pitfall",
125
162
  title: title,
126
163
  description: description,
127
164
  context: context,
128
165
  code_snippet: code_snippet,
129
166
  tags: tags,
130
- agent: agent
167
+ agent: agent,
168
+ confirm: confirm
131
169
  )
132
170
  end
133
171
 
134
- def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER)
135
- store_episode(
172
+ def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
173
+ store_episode(
136
174
  type: "lesson",
137
175
  title: title,
138
176
  description: description,
139
177
  context: context,
140
178
  code_snippet: code_snippet,
141
179
  tags: tags,
142
- agent: agent
180
+ agent: agent,
181
+ confirm: confirm
143
182
  )
144
183
  end
145
184
 
146
- def store_business_intent(title:, description:, constraints: [], tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, priority: 1)
185
+ def store_business_intent(title:, description:, constraints: [], tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, priority: 1, confirm: nil)
147
186
  context = constraints.any? ? "Constraints: #{constraints.join('; ')}" : ""
148
187
 
149
- store_episode(
188
+ store_episode(
150
189
  type: "business_intent",
151
190
  title: title,
152
191
  description: description,
153
192
  context: context,
154
193
  tags: tags,
155
- agent: agent,
194
+ agent: agent,
195
+ confirm: confirm,
156
196
  metadata: {
157
197
  "intent_kind" => "business",
158
198
  "constraints" => constraints,
@@ -162,18 +202,19 @@ module Agentf
162
202
  end
163
203
 
164
204
  def store_feature_intent(title:, description:, acceptance_criteria: [], non_goals: [], tags: [], agent: Agentf::AgentRoles::PLANNER,
165
- related_task_id: nil)
205
+ related_task_id: nil, confirm: nil)
166
206
  context_parts = []
167
207
  context_parts << "Acceptance: #{acceptance_criteria.join('; ')}" if acceptance_criteria.any?
168
208
  context_parts << "Non-goals: #{non_goals.join('; ')}" if non_goals.any?
169
209
 
170
- store_episode(
210
+ store_episode(
171
211
  type: "feature_intent",
172
212
  title: title,
173
213
  description: description,
174
214
  context: context_parts.join(" | "),
175
215
  tags: tags,
176
216
  agent: agent,
217
+ confirm: confirm,
177
218
  related_task_id: related_task_id,
178
219
  metadata: {
179
220
  "intent_kind" => "feature",
@@ -184,7 +225,7 @@ module Agentf
184
225
  end
185
226
 
186
227
  def store_incident(title:, description:, root_cause: "", resolution: "", tags: [], agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
187
- business_capability: nil)
228
+ business_capability: nil, confirm: nil)
188
229
  store_episode(
189
230
  type: "incident",
190
231
  title: title,
@@ -192,6 +233,7 @@ module Agentf
192
233
  context: ["Root cause: #{root_cause}", "Resolution: #{resolution}"].reject { |entry| entry.end_with?(": ") }.join(" | "),
193
234
  tags: tags,
194
235
  agent: agent,
236
+ confirm: confirm,
195
237
  metadata: {
196
238
  "root_cause" => root_cause,
197
239
  "resolution" => resolution,
@@ -201,7 +243,7 @@ module Agentf
201
243
  )
202
244
  end
203
245
 
204
- def store_playbook(title:, description:, steps: [], tags: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil)
246
+ def store_playbook(title:, description:, steps: [], tags: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil, confirm: nil)
205
247
  store_episode(
206
248
  type: "playbook",
207
249
  title: title,
@@ -209,6 +251,7 @@ module Agentf
209
251
  context: steps.any? ? "Steps: #{steps.join('; ')}" : "",
210
252
  tags: tags,
211
253
  agent: agent,
254
+ confirm: confirm,
212
255
  metadata: {
213
256
  "steps" => steps,
214
257
  "feature_area" => feature_area,
@@ -580,7 +623,7 @@ module Agentf
580
623
  cursor, batch = @client.scan(cursor, match: "episodic:*", count: 100)
581
624
  batch.each do |key|
582
625
  memory = load_episode(key)
583
- memories << memory if memory
626
+ memories << memory if memory && memory["project"].to_s == @project.to_s
584
627
  end
585
628
  break if cursor == "0"
586
629
  end
@@ -858,6 +901,41 @@ module Agentf
858
901
  base
859
902
  end
860
903
 
904
+ # NOTE: previous implementations exposed an `agent_requires_confirmation?`
905
+ # helper here. That functionality is superseded by
906
+ # `persistence_preference_for(agent)` which returns explicit preferences
907
+ # (:always, :ask_first, :never) and is used by callers to decide whether
908
+ # interactive confirmation is required. Keep this file lean and avoid
909
+ # duplicate helpers.
910
+
911
+ # Inspect loaded agent classes for explicit persistence preference.
912
+ # Returns one of: :always, :ask_first, :never, or nil when unknown.
913
+ def persistence_preference_for(agent)
914
+ begin
915
+ candidate = Agentf::Agents.constants
916
+ .map { |c| Agentf::Agents.const_get(c) }
917
+ .find do |klass|
918
+ klass.is_a?(Class) && klass.respond_to?(:policy_boundaries) && klass.typed_name == agent
919
+ end
920
+
921
+ return nil unless candidate
922
+
923
+ boundaries = candidate.policy_boundaries
924
+ persist_pattern = /persist|store|save/i
925
+
926
+ never_matches = Array(boundaries["never"]).select { |s| s =~ persist_pattern }
927
+ ask_matches = Array(boundaries["ask_first"]).select { |s| s =~ persist_pattern }
928
+ always_matches = Array(boundaries["always"]).select { |s| s =~ persist_pattern }
929
+
930
+ return :never if never_matches.any?
931
+ return :ask_first if ask_matches.any?
932
+ return :always if always_matches.any? && ask_matches.empty?
933
+ nil
934
+ rescue StandardError
935
+ nil
936
+ end
937
+ end
938
+
861
939
  def infer_division(agent)
862
940
  case agent
863
941
  when Agentf::AgentRoles::PLANNER, Agentf::AgentRoles::ORCHESTRATOR, Agentf::AgentRoles::KNOWLEDGE_MANAGER