agentf 0.4.7 → 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,10 +54,15 @@ 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,
57
+ def store_episode(type:, title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR,
48
58
  related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil, confirm: nil)
49
59
  # Determine persistence preference from the agent's policy boundaries.
50
- # Precedence: never > always > ask_first > none.
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.
51
66
  auto_confirm = ENV['AGENTF_AUTO_CONFIRM_MEMORIES'] == 'true'
52
67
  pref = persistence_preference_for(agent)
53
68
 
@@ -58,24 +73,13 @@ module Agentf
58
73
  rescue StandardError
59
74
  end
60
75
  return nil
61
- when :always
62
- # proceed without requiring explicit confirm
63
76
  when :ask_first
64
- unless agent == Agentf::AgentRoles::ORCHESTRATOR || confirm == true || auto_confirm
65
- begin
66
- puts "[MEMORY] Skipping persistence for #{agent}: confirmation required by policy"
67
- rescue StandardError
68
- end
69
- return nil
70
- end
71
- else
72
- # default conservative behavior: require explicit confirm (or env opt-in)
73
- unless agent == Agentf::AgentRoles::ORCHESTRATOR || confirm == true || auto_confirm
74
- begin
75
- puts "[MEMORY] Skipping persistence for #{agent}: confirmation required"
76
- rescue StandardError
77
- end
78
- return nil
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)
79
83
  end
80
84
  end
81
85
  episode_id = "episode_#{SecureRandom.hex(4)}"
@@ -139,7 +143,7 @@ module Agentf
139
143
  episode_id
140
144
  end
141
145
 
142
- def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
146
+ def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
143
147
  store_episode(
144
148
  type: "success",
145
149
  title: title,
@@ -152,7 +156,7 @@ module Agentf
152
156
  )
153
157
  end
154
158
 
155
- def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
159
+ def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
156
160
  store_episode(
157
161
  type: "pitfall",
158
162
  title: title,
@@ -165,7 +169,7 @@ module Agentf
165
169
  )
166
170
  end
167
171
 
168
- def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
172
+ def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
169
173
  store_episode(
170
174
  type: "lesson",
171
175
  title: title,
@@ -221,7 +225,7 @@ module Agentf
221
225
  end
222
226
 
223
227
  def store_incident(title:, description:, root_cause: "", resolution: "", tags: [], agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
224
- business_capability: nil)
228
+ business_capability: nil, confirm: nil)
225
229
  store_episode(
226
230
  type: "incident",
227
231
  title: title,
@@ -229,6 +233,7 @@ module Agentf
229
233
  context: ["Root cause: #{root_cause}", "Resolution: #{resolution}"].reject { |entry| entry.end_with?(": ") }.join(" | "),
230
234
  tags: tags,
231
235
  agent: agent,
236
+ confirm: confirm,
232
237
  metadata: {
233
238
  "root_cause" => root_cause,
234
239
  "resolution" => resolution,
@@ -238,7 +243,7 @@ module Agentf
238
243
  )
239
244
  end
240
245
 
241
- 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)
242
247
  store_episode(
243
248
  type: "playbook",
244
249
  title: title,
@@ -246,6 +251,7 @@ module Agentf
246
251
  context: steps.any? ? "Steps: #{steps.join('; ')}" : "",
247
252
  tags: tags,
248
253
  agent: agent,
254
+ confirm: confirm,
249
255
  metadata: {
250
256
  "steps" => steps,
251
257
  "feature_area" => feature_area,
@@ -617,7 +623,7 @@ module Agentf
617
623
  cursor, batch = @client.scan(cursor, match: "episodic:*", count: 100)
618
624
  batch.each do |key|
619
625
  memory = load_episode(key)
620
- memories << memory if memory
626
+ memories << memory if memory && memory["project"].to_s == @project.to_s
621
627
  end
622
628
  break if cursor == "0"
623
629
  end
@@ -895,31 +901,12 @@ module Agentf
895
901
  base
896
902
  end
897
903
 
898
- # Determine whether writes from the given agent should require explicit
899
- # confirmation. We consider agents that declare "ask_first" policy
900
- # boundaries to be conservative and require confirmation before persisting
901
- # memories. Agent classes register policy boundaries on their class
902
- # definitions (see agents/*). When agent is provided as a role string
903
- # (eg. "ENGINEER") we try to match against known agent classes.
904
- def agent_requires_confirmation?(agent)
905
- begin
906
- # If a developer passed an agent class-like string, try to map it to
907
- # the loaded agent class and inspect its policy_boundaries.
908
- candidate = Agentf::Agents.constants
909
- .map { |c| Agentf::Agents.const_get(c) }
910
- .find do |klass|
911
- klass.is_a?(Class) && klass.respond_to?(:policy_boundaries) && klass.typed_name == agent
912
- end
913
-
914
- return false unless candidate
915
-
916
- boundaries = candidate.policy_boundaries
917
- ask_first = Array(boundaries["ask_first"]) rescue []
918
- !ask_first.empty?
919
- rescue StandardError
920
- false
921
- end
922
- end
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.
923
910
 
924
911
  # Inspect loaded agent classes for explicit persistence preference.
925
912
  # Returns one of: :always, :ask_first, :never, or nil when unknown.
@@ -934,9 +921,15 @@ module Agentf
934
921
  return nil unless candidate
935
922
 
936
923
  boundaries = candidate.policy_boundaries
937
- return :never if Array(boundaries["never"]).any?
938
- return :always if Array(boundaries["always"]).any? && Array(boundaries["ask_first"]).empty?
939
- return :ask_first if Array(boundaries["ask_first"]).any?
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?
940
933
  nil
941
934
  rescue StandardError
942
935
  nil