agentf 0.4.7 → 0.6.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.
data/lib/agentf/memory.rb CHANGED
@@ -10,17 +10,33 @@ module Agentf
10
10
  module Memory
11
11
  # Redis-backed memory system for agent learning
12
12
  class RedisMemory
13
+ EPISODIC_INDEX = "episodic:logs"
13
14
  EDGE_INDEX = "edge:links"
15
+ DEFAULT_SEMANTIC_SCAN_LIMIT = 200
16
+ VECTOR_DIMENSIONS = defined?(Agentf::EmbeddingProvider::DIMENSIONS) ? Agentf::EmbeddingProvider::DIMENSIONS : 64
14
17
 
15
18
  attr_reader :project
16
19
 
17
- def initialize(redis_url: nil, project: nil)
20
+ def initialize(redis_url: nil, project: nil, embedding_provider: Agentf::EmbeddingProvider.new)
18
21
  @redis_url = redis_url || Agentf.config.redis_url
19
22
  @project = project || Agentf.config.project_name
23
+ @embedding_provider = embedding_provider
20
24
  @client = Redis.new(client_options)
21
25
  @json_supported = detect_json_support
22
26
  @search_supported = detect_search_support
27
+ @vector_search_supported = false
23
28
  ensure_indexes if @search_supported
29
+ @vector_search_supported = detect_vector_search_support if @search_supported
30
+ end
31
+
32
+ # Raised when a write requires explicit user confirmation (ask_first).
33
+ class ConfirmationRequired < StandardError
34
+ attr_reader :details
35
+
36
+ def initialize(message = "confirmation required to persist memory", details = {})
37
+ super(message)
38
+ @details = details
39
+ end
24
40
  end
25
41
 
26
42
  def store_task(content:, embedding: [], language: nil, task_type: nil, success: true, agent: Agentf::AgentRoles::PLANNER)
@@ -44,10 +60,16 @@ module Agentf
44
60
  task_id
45
61
  end
46
62
 
47
- def store_episode(type:, title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER,
63
+ def store_episode(type:, title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR,
64
+ outcome: nil, embedding: nil,
48
65
  related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil, confirm: nil)
49
66
  # Determine persistence preference from the agent's policy boundaries.
50
- # Precedence: never > always > ask_first > none.
67
+ # Precedence: never > ask_first > always > none.
68
+ # For local/dev testing we may bypass interactive confirmation when
69
+ # AGENTF_AUTO_CONFIRM_MEMORIES=true. Otherwise, when an agent declares
70
+ # an "ask_first" persistence preference we raise ConfirmationRequired
71
+ # so higher-level code (MCP server / workflow engine / CLI) can prompt
72
+ # the user and retry the write with confirm: true.
51
73
  auto_confirm = ENV['AGENTF_AUTO_CONFIRM_MEMORIES'] == 'true'
52
74
  pref = persistence_preference_for(agent)
53
75
 
@@ -58,24 +80,13 @@ module Agentf
58
80
  rescue StandardError
59
81
  end
60
82
  return nil
61
- when :always
62
- # proceed without requiring explicit confirm
63
83
  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
84
+ # If the agent's policy requires asking first, and we do not have
85
+ # an explicit confirmation (confirm: true) and auto_confirm is not
86
+ # enabled, raise ConfirmationRequired so callers can handle prompting.
87
+ unless auto_confirm || confirm == true
88
+ details = { "reason" => "ask_first", "agent" => agent.to_s, "attempted" => { "type" => type, "title" => title } }
89
+ raise ConfirmationRequired.new("confirm", details)
79
90
  end
80
91
  end
81
92
  episode_id = "episode_#{SecureRandom.hex(4)}"
@@ -83,22 +94,28 @@ module Agentf
83
94
  metadata: metadata,
84
95
  agent: agent,
85
96
  type: type,
86
- tags: tags,
87
97
  entity_ids: entity_ids,
88
98
  relationships: relationships,
89
99
  parent_episode_id: parent_episode_id,
90
- causal_from: causal_from
100
+ causal_from: causal_from,
101
+ outcome: outcome
91
102
  )
103
+ supplied_embedding = parse_embedding(embedding)
104
+ embedding_vector = if supplied_embedding.any?
105
+ normalize_vector_dimensions(supplied_embedding)
106
+ else
107
+ embed_text(episode_embedding_text(title: title, description: description, context: context, code_snippet: code_snippet, metadata: normalized_metadata))
108
+ end
92
109
 
93
110
  data = {
94
111
  "id" => episode_id,
95
112
  "type" => type,
113
+ "outcome" => normalize_outcome(outcome),
96
114
  "title" => title,
97
115
  "description" => description,
98
116
  "project" => @project,
99
117
  "context" => context,
100
118
  "code_snippet" => code_snippet,
101
- "tags" => tags,
102
119
  "created_at" => Time.now.to_i,
103
120
  "agent" => agent,
104
121
  "related_task_id" => related_task_id || "",
@@ -106,7 +123,8 @@ module Agentf
106
123
  "relationships" => relationships,
107
124
  "parent_episode_id" => parent_episode_id.to_s,
108
125
  "causal_from" => causal_from.to_s,
109
- "metadata" => normalized_metadata
126
+ "metadata" => normalized_metadata,
127
+ "embedding" => embedding_vector
110
128
  }
111
129
 
112
130
  key = "episodic:#{episode_id}"
@@ -132,63 +150,34 @@ module Agentf
132
150
  related_task_id: related_task_id,
133
151
  relationships: relationships,
134
152
  metadata: normalized_metadata,
135
- tags: tags,
136
153
  agent: agent
137
154
  )
138
155
 
139
156
  episode_id
140
157
  end
141
158
 
142
- def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
159
+ def store_lesson(title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
143
160
  store_episode(
144
- type: "success",
145
- title: title,
146
- description: description,
147
- context: context,
148
- code_snippet: code_snippet,
149
- tags: tags,
150
- agent: agent,
151
- confirm: confirm
152
- )
153
- end
154
-
155
- def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
156
- store_episode(
157
- type: "pitfall",
158
- title: title,
159
- description: description,
160
- context: context,
161
- code_snippet: code_snippet,
162
- tags: tags,
163
- agent: agent,
164
- confirm: confirm
165
- )
166
- end
167
-
168
- def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
169
- store_episode(
170
- type: "lesson",
171
- title: title,
172
- description: description,
173
- context: context,
174
- code_snippet: code_snippet,
175
- tags: tags,
176
- agent: agent,
177
- confirm: confirm
178
- )
179
- end
161
+ type: "lesson",
162
+ title: title,
163
+ description: description,
164
+ context: context,
165
+ code_snippet: code_snippet,
166
+ agent: agent,
167
+ confirm: confirm
168
+ )
169
+ end
180
170
 
181
- def store_business_intent(title:, description:, constraints: [], tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, priority: 1, confirm: nil)
171
+ def store_business_intent(title:, description:, constraints: [], agent: Agentf::AgentRoles::ORCHESTRATOR, priority: 1, confirm: nil)
182
172
  context = constraints.any? ? "Constraints: #{constraints.join('; ')}" : ""
183
173
 
184
174
  store_episode(
185
175
  type: "business_intent",
186
- title: title,
187
- description: description,
188
- context: context,
189
- tags: tags,
190
- agent: agent,
191
- confirm: confirm,
176
+ title: title,
177
+ description: description,
178
+ context: context,
179
+ agent: agent,
180
+ confirm: confirm,
192
181
  metadata: {
193
182
  "intent_kind" => "business",
194
183
  "constraints" => constraints,
@@ -197,7 +186,7 @@ module Agentf
197
186
  )
198
187
  end
199
188
 
200
- def store_feature_intent(title:, description:, acceptance_criteria: [], non_goals: [], tags: [], agent: Agentf::AgentRoles::PLANNER,
189
+ def store_feature_intent(title:, description:, acceptance_criteria: [], non_goals: [], agent: Agentf::AgentRoles::PLANNER,
201
190
  related_task_id: nil, confirm: nil)
202
191
  context_parts = []
203
192
  context_parts << "Acceptance: #{acceptance_criteria.join('; ')}" if acceptance_criteria.any?
@@ -205,12 +194,11 @@ module Agentf
205
194
 
206
195
  store_episode(
207
196
  type: "feature_intent",
208
- title: title,
209
- description: description,
210
- context: context_parts.join(" | "),
211
- tags: tags,
212
- agent: agent,
213
- confirm: confirm,
197
+ title: title,
198
+ description: description,
199
+ context: context_parts.join(" | "),
200
+ agent: agent,
201
+ confirm: confirm,
214
202
  related_task_id: related_task_id,
215
203
  metadata: {
216
204
  "intent_kind" => "feature",
@@ -220,15 +208,15 @@ module Agentf
220
208
  )
221
209
  end
222
210
 
223
- def store_incident(title:, description:, root_cause: "", resolution: "", tags: [], agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
224
- business_capability: nil)
211
+ def store_incident(title:, description:, root_cause: "", resolution: "", agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
212
+ business_capability: nil, confirm: nil)
225
213
  store_episode(
226
214
  type: "incident",
227
215
  title: title,
228
216
  description: description,
229
217
  context: ["Root cause: #{root_cause}", "Resolution: #{resolution}"].reject { |entry| entry.end_with?(": ") }.join(" | "),
230
- tags: tags,
231
218
  agent: agent,
219
+ confirm: confirm,
232
220
  metadata: {
233
221
  "root_cause" => root_cause,
234
222
  "resolution" => resolution,
@@ -238,14 +226,14 @@ module Agentf
238
226
  )
239
227
  end
240
228
 
241
- def store_playbook(title:, description:, steps: [], tags: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil)
229
+ def store_playbook(title:, description:, steps: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil, confirm: nil)
242
230
  store_episode(
243
231
  type: "playbook",
244
232
  title: title,
245
233
  description: description,
246
234
  context: steps.any? ? "Steps: #{steps.join('; ')}" : "",
247
- tags: tags,
248
235
  agent: agent,
236
+ confirm: confirm,
249
237
  metadata: {
250
238
  "steps" => steps,
251
239
  "feature_area" => feature_area,
@@ -306,13 +294,18 @@ module Agentf
306
294
  end
307
295
 
308
296
  def get_relevant_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
309
- get_agent_context(agent: agent, query_embedding: query_embedding, task_type: task_type, limit: limit)
297
+ get_agent_context(agent: agent, query_embedding: query_embedding, query_text: nil, task_type: task_type, limit: limit)
310
298
  end
311
299
 
312
- def get_agent_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
300
+ def get_agent_context(agent:, query_embedding: nil, query_text: nil, task_type: nil, limit: 8)
313
301
  profile = context_profile(agent)
314
- candidates = get_recent_memories(limit: [limit * 8, 200].min)
315
- ranked = rank_memories(candidates: candidates, agent: agent, profile: profile)
302
+ query_vector = normalized_query_embedding(query_embedding: query_embedding, query_text: query_text)
303
+ candidates = if vector_search_supported? && query_vector.any?
304
+ vector_search_candidates(query_embedding: query_vector, limit: DEFAULT_SEMANTIC_SCAN_LIMIT)
305
+ else
306
+ collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
307
+ end
308
+ ranked = rank_memories(candidates: candidates, agent: agent, profile: profile, query_embedding: query_vector)
316
309
 
317
310
  {
318
311
  "agent" => agent,
@@ -323,12 +316,11 @@ module Agentf
323
316
  }
324
317
  end
325
318
 
326
- def get_pitfalls(limit: 10)
327
- if @search_supported
328
- search_episodic(query: "@type:pitfall @project:{#{@project}}", limit: limit)
329
- else
330
- fetch_memories_without_search(limit: [limit * 4, 100].min).select { |mem| mem["type"] == "pitfall" }.first(limit)
331
- end
319
+ def get_episodes(limit: 10, outcome: nil)
320
+ memories = fetch_memories_without_search(limit: [limit * 8, DEFAULT_SEMANTIC_SCAN_LIMIT].min)
321
+ memories = memories.select { |mem| mem["type"] == "episode" }
322
+ memories = memories.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
323
+ memories.first(limit)
332
324
  end
333
325
 
334
326
  def get_recent_memories(limit: 10)
@@ -339,16 +331,6 @@ module Agentf
339
331
  end
340
332
  end
341
333
 
342
- def get_all_tags
343
- memories = get_recent_memories(limit: 100)
344
- all_tags = Set.new
345
- memories.each do |mem|
346
- tags = mem["tags"]
347
- all_tags.merge(tags) if tags.is_a?(Array)
348
- end
349
- all_tags.to_a
350
- end
351
-
352
334
  def delete_memory_by_id(id:, scope: "project", dry_run: false)
353
335
  normalized_scope = normalize_scope(scope)
354
336
  episode_id = normalize_episode_id(id)
@@ -411,7 +393,7 @@ module Agentf
411
393
  )
412
394
  end
413
395
 
414
- def store_edge(source_id:, target_id:, relation:, weight: 1.0, tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, metadata: {})
396
+ def store_edge(source_id:, target_id:, relation:, weight: 1.0, agent: Agentf::AgentRoles::ORCHESTRATOR, metadata: {})
415
397
  edge_id = "edge_#{SecureRandom.hex(5)}"
416
398
  data = {
417
399
  "id" => edge_id,
@@ -419,7 +401,6 @@ module Agentf
419
401
  "target_id" => target_id,
420
402
  "relation" => relation,
421
403
  "weight" => weight.to_f,
422
- "tags" => tags,
423
404
  "project" => @project,
424
405
  "agent" => agent,
425
406
  "metadata" => metadata,
@@ -471,34 +452,12 @@ module Agentf
471
452
  end
472
453
 
473
454
  def create_episodic_index
474
- @client.call(
475
- "FT.CREATE", "episodic:logs",
476
- "ON", "JSON",
477
- "PREFIX", "1", "episodic:",
478
- "SCHEMA",
479
- "$.id", "AS", "id", "TEXT",
480
- "$.type", "AS", "type", "TEXT",
481
- "$.title", "AS", "title", "TEXT",
482
- "$.description", "AS", "description", "TEXT",
483
- "$.project", "AS", "project", "TAG",
484
- "$.context", "AS", "context", "TEXT",
485
- "$.code_snippet", "AS", "code_snippet", "TEXT",
486
- "$.tags", "AS", "tags", "TAG",
487
- "$.created_at", "AS", "created_at", "NUMERIC",
488
- "$.agent", "AS", "agent", "TEXT",
489
- "$.related_task_id", "AS", "related_task_id", "TEXT",
490
- "$.metadata.intent_kind", "AS", "intent_kind", "TAG",
491
- "$.metadata.priority", "AS", "priority", "NUMERIC",
492
- "$.metadata.confidence", "AS", "confidence", "NUMERIC",
493
- "$.metadata.business_capability", "AS", "business_capability", "TAG",
494
- "$.metadata.feature_area", "AS", "feature_area", "TAG",
495
- "$.metadata.agent_role", "AS", "agent_role", "TAG",
496
- "$.metadata.division", "AS", "division", "TAG",
497
- "$.metadata.specialty", "AS", "specialty", "TAG",
498
- "$.entity_ids[*]", "AS", "entity_ids", "TAG",
499
- "$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
500
- "$.causal_from", "AS", "causal_from", "TEXT"
501
- )
455
+ @client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: true))
456
+ rescue Redis::CommandError => e
457
+ raise if index_already_exists?(e)
458
+ raise unless vector_query_unsupported?(e)
459
+
460
+ @client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: false))
502
461
  end
503
462
 
504
463
  def create_edge_index
@@ -514,38 +473,19 @@ module Agentf
514
473
  "$.project", "AS", "project", "TAG",
515
474
  "$.agent", "AS", "agent", "TAG",
516
475
  "$.weight", "AS", "weight", "NUMERIC",
517
- "$.created_at", "AS", "created_at", "NUMERIC",
518
- "$.tags", "AS", "tags", "TAG"
476
+ "$.created_at", "AS", "created_at", "NUMERIC"
519
477
  )
520
478
  end
521
479
 
522
480
  def search_episodic(query:, limit:)
523
481
  results = @client.call(
524
- "FT.SEARCH", "episodic:logs",
482
+ "FT.SEARCH", EPISODIC_INDEX,
525
483
  query,
526
484
  "SORTBY", "created_at", "DESC",
527
485
  "LIMIT", "0", limit.to_s
528
486
  )
529
487
 
530
- return [] unless results && results[0] > 0
531
-
532
- memories = []
533
- (2...results.length).step(2) do |i|
534
- item = results[i]
535
- if item.is_a?(Array)
536
- item.each_with_index do |part, j|
537
- if part == "$" && j + 1 < item.length
538
- begin
539
- memory = JSON.parse(item[j + 1])
540
- memories << memory
541
- rescue JSON::ParserError
542
- # Skip invalid JSON
543
- end
544
- end
545
- end
546
- end
547
- end
548
- memories
488
+ parse_search_results(results)
549
489
  end
550
490
 
551
491
  def index_already_exists?(error)
@@ -579,7 +519,7 @@ module Agentf
579
519
  end
580
520
 
581
521
  def detect_search_support
582
- @client.call("FT.INFO", "episodic:logs")
522
+ @client.call("FT.INFO", EPISODIC_INDEX)
583
523
  true
584
524
  rescue Redis::CommandError => e
585
525
  return true if index_missing_error?(e)
@@ -588,6 +528,28 @@ module Agentf
588
528
  raise Redis::CommandError, "Failed to check RediSearch availability: #{e.message}"
589
529
  end
590
530
 
531
+ def detect_vector_search_support
532
+ return false unless @search_supported
533
+
534
+ info = @client.call("FT.INFO", EPISODIC_INDEX)
535
+ return false unless info.to_s.upcase.include?("VECTOR")
536
+
537
+ @client.call(
538
+ "FT.SEARCH", EPISODIC_INDEX,
539
+ "*=>[KNN 1 @embedding $query_vector AS vector_distance]",
540
+ "PARAMS", "2", "query_vector", pack_vector(Array.new(VECTOR_DIMENSIONS, 0.0)),
541
+ "SORTBY", "vector_distance", "ASC",
542
+ "RETURN", "2", "$", "vector_distance",
543
+ "DIALECT", "2",
544
+ "LIMIT", "0", "1"
545
+ )
546
+ true
547
+ rescue Redis::CommandError => e
548
+ return false if index_missing_error?(e) || vector_query_unsupported?(e)
549
+
550
+ raise Redis::CommandError, "Failed to check Redis vector search availability: #{e.message}"
551
+ end
552
+
591
553
  def index_missing_error?(error)
592
554
  message = error.message
593
555
  return false unless message
@@ -617,7 +579,7 @@ module Agentf
617
579
  cursor, batch = @client.scan(cursor, match: "episodic:*", count: 100)
618
580
  batch.each do |key|
619
581
  memory = load_episode(key)
620
- memories << memory if memory
582
+ memories << memory if memory && memory["project"].to_s == @project.to_s
621
583
  end
622
584
  break if cursor == "0"
623
585
  end
@@ -628,21 +590,21 @@ module Agentf
628
590
  def context_profile(agent)
629
591
  case agent.to_s.upcase
630
592
  when Agentf::AgentRoles::PLANNER
631
- { "preferred_types" => %w[business_intent feature_intent lesson playbook pitfall], "pitfall_penalty" => 0.1 }
593
+ { "preferred_types" => %w[business_intent feature_intent lesson playbook episode], "negative_outcome_penalty" => 0.1 }
632
594
  when Agentf::AgentRoles::ENGINEER
633
- { "preferred_types" => %w[playbook success lesson pitfall], "pitfall_penalty" => 0.05 }
595
+ { "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
634
596
  when Agentf::AgentRoles::QA_TESTER
635
- { "preferred_types" => %w[lesson pitfall incident success], "pitfall_penalty" => 0.0 }
597
+ { "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
636
598
  when Agentf::AgentRoles::INCIDENT_RESPONDER
637
- { "preferred_types" => %w[incident pitfall lesson], "pitfall_penalty" => 0.0 }
599
+ { "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
638
600
  when Agentf::AgentRoles::SECURITY_REVIEWER
639
- { "preferred_types" => %w[pitfall lesson incident], "pitfall_penalty" => 0.0 }
601
+ { "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
640
602
  else
641
- { "preferred_types" => %w[lesson pitfall success business_intent feature_intent], "pitfall_penalty" => 0.05 }
603
+ { "preferred_types" => %w[lesson episode business_intent feature_intent], "negative_outcome_penalty" => 0.05 }
642
604
  end
643
605
  end
644
606
 
645
- def rank_memories(candidates:, agent:, profile:)
607
+ def rank_memories(candidates:, agent:, profile:, query_embedding: nil)
646
608
  now = Time.now.to_i
647
609
  preferred_types = Array(profile["preferred_types"])
648
610
 
@@ -654,19 +616,41 @@ module Agentf
654
616
  confidence = metadata.fetch("confidence", 0.6).to_f
655
617
  confidence = 0.0 if confidence.negative?
656
618
  confidence = 1.0 if confidence > 1.0
619
+ semantic_score = cosine_similarity(query_embedding, parse_embedding(memory["embedding"]))
657
620
 
658
621
  type_score = preferred_types.include?(type) ? 1.0 : 0.25
659
622
  agent_score = (memory["agent"] == agent || memory["agent"] == Agentf::AgentRoles::ORCHESTRATOR) ? 1.0 : 0.2
660
623
  age_seconds = [now - memory.fetch("created_at", now).to_i, 0].max
661
624
  recency_score = 1.0 / (1.0 + (age_seconds / 86_400.0))
662
625
 
663
- pitfall_penalty = type == "pitfall" ? profile.fetch("pitfall_penalty", 0.0).to_f : 0.0
664
- memory["rank_score"] = ((0.45 * type_score) + (0.3 * agent_score) + (0.2 * recency_score) + (0.05 * confidence) - pitfall_penalty).round(6)
626
+ negative_outcome_penalty = memory["outcome"] == "negative" ? profile.fetch("negative_outcome_penalty", 0.0).to_f : 0.0
627
+ memory["rank_score"] = ((0.4 * semantic_score) + (0.22 * type_score) + (0.18 * agent_score) + (0.15 * recency_score) + (0.05 * confidence) - negative_outcome_penalty).round(6)
665
628
  memory
666
629
  end
667
630
  .sort_by { |memory| -memory["rank_score"] }
668
631
  end
669
632
 
633
+ public def search_memories(query:, limit: 10, type: nil, agent: nil, outcome: nil)
634
+ query_vector = embed_text(query)
635
+ candidates = if vector_search_supported? && query_vector.any?
636
+ native = vector_search_episodes(query_embedding: query_vector, limit: limit, type: type, agent: agent, outcome: outcome)
637
+ native.empty? ? collect_episode_records(scope: "project", type: type, agent: agent) : native
638
+ else
639
+ collect_episode_records(scope: "project", type: type, agent: agent)
640
+ end
641
+ candidates = candidates.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
642
+
643
+ ranked = candidates.map do |memory|
644
+ score = cosine_similarity(query_vector, parse_embedding(memory["embedding"]))
645
+ lexical = lexical_overlap_score(query, memory)
646
+ next if score <= 0 && lexical <= 0
647
+
648
+ memory.merge("score" => ((0.75 * score) + (0.25 * lexical)).round(6))
649
+ end.compact
650
+
651
+ ranked.sort_by { |memory| -memory["score"] }.first(limit)
652
+ end
653
+
670
654
  def load_episode(key)
671
655
  raw = if @json_supported
672
656
  begin
@@ -701,6 +685,37 @@ module Agentf
701
685
  []
702
686
  end
703
687
 
688
+ def parse_search_results(results)
689
+ return [] unless results && results[0].to_i.positive?
690
+
691
+ records = []
692
+ (2...results.length).step(2) do |i|
693
+ item = results[i]
694
+ next unless item.is_a?(Array)
695
+
696
+ record = {}
697
+ item.each_slice(2) do |field, value|
698
+ next if value.nil?
699
+
700
+ if field == "$"
701
+ begin
702
+ payload = JSON.parse(value)
703
+ record.merge!(payload) if payload.is_a?(Hash)
704
+ rescue JSON::ParserError
705
+ record = nil
706
+ break
707
+ end
708
+ else
709
+ record[field] = value
710
+ end
711
+ end
712
+
713
+ records << record if record.is_a?(Hash) && record.any?
714
+ end
715
+
716
+ records
717
+ end
718
+
704
719
  def cosine_similarity(a, b)
705
720
  return 0.0 if a.empty? || b.empty? || a.length != b.length
706
721
 
@@ -748,6 +763,53 @@ module Agentf
748
763
  memories
749
764
  end
750
765
 
766
+ def vector_search_episodes(query_embedding:, limit:, type: nil, agent: nil, outcome: nil)
767
+ return [] unless vector_search_supported?
768
+
769
+ requested_limit = [limit.to_i, 1].max
770
+ search_limit = [requested_limit * 4, 10].max
771
+ filters = ["@project:{#{escape_tag(@project)}}"]
772
+ normalized_outcome = normalize_outcome(outcome)
773
+ filters << "@outcome:{#{escape_tag(normalized_outcome)}}" if normalized_outcome
774
+ base_query = filters.join(" ")
775
+
776
+ results = @client.call(
777
+ "FT.SEARCH", EPISODIC_INDEX,
778
+ "#{base_query}=>[KNN #{search_limit} @embedding $query_vector AS vector_distance]",
779
+ "PARAMS", "2", "query_vector", pack_vector(query_embedding),
780
+ "SORTBY", "vector_distance", "ASC",
781
+ "RETURN", "2", "$", "vector_distance",
782
+ "DIALECT", "2",
783
+ "LIMIT", "0", search_limit.to_s
784
+ )
785
+
786
+ parse_search_results(results)
787
+ .select do |memory|
788
+ next false unless memory["project"].to_s == @project.to_s
789
+ next false unless type.to_s.empty? || memory["type"].to_s == type.to_s
790
+ next false unless agent.to_s.empty? || memory["agent"].to_s == agent.to_s
791
+ next false unless normalized_outcome.nil? || memory["outcome"].to_s == normalized_outcome
792
+
793
+ true
794
+ end
795
+ .each { |memory| memory["vector_distance"] = memory["vector_distance"].to_f if memory.key?("vector_distance") }
796
+ .first(requested_limit)
797
+ rescue Redis::CommandError => e
798
+ if vector_query_unsupported?(e)
799
+ @vector_search_supported = false
800
+ return []
801
+ end
802
+
803
+ raise
804
+ end
805
+
806
+ def vector_search_candidates(query_embedding:, limit:)
807
+ native = vector_search_episodes(query_embedding: query_embedding, limit: limit)
808
+ return native if native.any?
809
+
810
+ collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
811
+ end
812
+
751
813
  def collect_related_edge_keys(episode_ids:, scope:)
752
814
  ids = episode_ids.map(&:to_s).reject(&:empty?).to_set
753
815
  return [] if ids.empty?
@@ -844,9 +906,9 @@ module Agentf
844
906
  }
845
907
  end
846
908
 
847
- def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:, tags:, agent:)
909
+ def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:, agent:)
848
910
  if related_task_id && !related_task_id.to_s.strip.empty?
849
- store_edge(source_id: episode_id, target_id: related_task_id, relation: "relates_to", tags: tags, agent: agent)
911
+ store_edge(source_id: episode_id, target_id: related_task_id, relation: "relates_to", agent: agent)
850
912
  end
851
913
 
852
914
  Array(relationships).each do |relation|
@@ -861,7 +923,6 @@ module Agentf
861
923
  target_id: target,
862
924
  relation: relation_type,
863
925
  weight: (relation["weight"] || relation[:weight] || 1.0).to_f,
864
- tags: tags,
865
926
  agent: agent,
866
927
  metadata: { "source_metadata" => extract_metadata_slice(metadata, %w[intent_kind agent_role division]) }
867
928
  )
@@ -869,58 +930,89 @@ module Agentf
869
930
 
870
931
  parent = metadata["parent_episode_id"].to_s
871
932
  unless parent.empty?
872
- store_edge(source_id: episode_id, target_id: parent, relation: "child_of", tags: tags, agent: agent)
933
+ store_edge(source_id: episode_id, target_id: parent, relation: "child_of", agent: agent)
873
934
  end
874
935
 
875
936
  causal_from = metadata["causal_from"].to_s
876
937
  unless causal_from.empty?
877
- store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by", tags: tags, agent: agent)
938
+ store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by", agent: agent)
878
939
  end
879
940
  rescue StandardError
880
941
  nil
881
942
  end
882
943
 
883
- def enrich_metadata(metadata:, agent:, type:, tags:, entity_ids:, relationships:, parent_episode_id:, causal_from:)
944
+ def enrich_metadata(metadata:, agent:, type:, entity_ids:, relationships:, parent_episode_id:, causal_from:, outcome:)
884
945
  base = metadata.is_a?(Hash) ? metadata.dup : {}
885
946
  base["agent_role"] = agent
886
947
  base["division"] = infer_division(agent)
887
948
  base["specialty"] = infer_specialty(agent)
888
949
  base["capabilities"] = infer_capabilities(agent)
889
950
  base["episode_type"] = type
890
- base["tag_count"] = Array(tags).length
891
951
  base["relationship_count"] = Array(relationships).length
892
952
  base["entity_ids"] = Array(entity_ids)
953
+ base["outcome"] = normalize_outcome(outcome) if outcome
893
954
  base["parent_episode_id"] = parent_episode_id.to_s unless parent_episode_id.to_s.empty?
894
955
  base["causal_from"] = causal_from.to_s unless causal_from.to_s.empty?
895
956
  base
896
957
  end
897
958
 
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
959
+ def normalized_query_embedding(query_embedding:, query_text:)
960
+ embedded = parse_embedding(query_embedding)
961
+ return embedded if embedded.any?
913
962
 
914
- return false unless candidate
963
+ embed_text(query_text)
964
+ end
915
965
 
916
- boundaries = candidate.policy_boundaries
917
- ask_first = Array(boundaries["ask_first"]) rescue []
918
- !ask_first.empty?
919
- rescue StandardError
920
- false
921
- end
966
+ def episode_embedding_text(title:, description:, context:, code_snippet:, metadata:)
967
+ [
968
+ title,
969
+ description,
970
+ context,
971
+ code_snippet,
972
+ metadata["feature_area"],
973
+ metadata["business_capability"],
974
+ metadata["intent_kind"],
975
+ metadata["resolution"],
976
+ metadata["root_cause"]
977
+ ].compact.join("\n")
922
978
  end
923
979
 
980
+ def lexical_overlap_score(query, memory)
981
+ query_tokens = normalize_tokens(query)
982
+ return 0.0 if query_tokens.empty?
983
+
984
+ memory_tokens = normalize_tokens([memory["title"], memory["description"], memory["context"], memory["code_snippet"]].compact.join(" "))
985
+ return 0.0 if memory_tokens.empty?
986
+
987
+ overlap = (query_tokens & memory_tokens).length
988
+ overlap.to_f / query_tokens.length.to_f
989
+ end
990
+
991
+ def normalize_tokens(text)
992
+ text.to_s.downcase.scan(/[a-z0-9_]+/).reject { |token| token.length < 2 }
993
+ end
994
+
995
+ def embed_text(text)
996
+ @embedding_provider.embed(text)
997
+ end
998
+
999
+ def normalize_outcome(value)
1000
+ normalized = value.to_s.strip.downcase
1001
+ return nil if normalized.empty?
1002
+ return "positive" if %w[positive success succeeded passed pass completed approved].include?(normalized)
1003
+ return "negative" if %w[negative failure failed fail pitfall error blocked violated].include?(normalized)
1004
+ return "neutral" if %w[neutral info informational lesson observation].include?(normalized)
1005
+
1006
+ normalized
1007
+ end
1008
+
1009
+ # NOTE: previous implementations exposed an `agent_requires_confirmation?`
1010
+ # helper here. That functionality is superseded by
1011
+ # `persistence_preference_for(agent)` which returns explicit preferences
1012
+ # (:always, :ask_first, :never) and is used by callers to decide whether
1013
+ # interactive confirmation is required. Keep this file lean and avoid
1014
+ # duplicate helpers.
1015
+
924
1016
  # Inspect loaded agent classes for explicit persistence preference.
925
1017
  # Returns one of: :always, :ask_first, :never, or nil when unknown.
926
1018
  def persistence_preference_for(agent)
@@ -934,9 +1026,15 @@ module Agentf
934
1026
  return nil unless candidate
935
1027
 
936
1028
  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?
1029
+ persist_pattern = /persist|store|save/i
1030
+
1031
+ never_matches = Array(boundaries["never"]).select { |s| s =~ persist_pattern }
1032
+ ask_matches = Array(boundaries["ask_first"]).select { |s| s =~ persist_pattern }
1033
+ always_matches = Array(boundaries["always"]).select { |s| s =~ persist_pattern }
1034
+
1035
+ return :never if never_matches.any?
1036
+ return :ask_first if ask_matches.any?
1037
+ return :always if always_matches.any? && ask_matches.empty?
940
1038
  nil
941
1039
  rescue StandardError
942
1040
  nil
@@ -1071,24 +1169,7 @@ module Agentf
1071
1169
  "SORTBY", "created_at", "DESC",
1072
1170
  "LIMIT", "0", limit.to_s
1073
1171
  )
1074
- return [] unless results && results[0] > 0
1075
-
1076
- records = []
1077
- (2...results.length).step(2) do |i|
1078
- item = results[i]
1079
- next unless item.is_a?(Array)
1080
-
1081
- item.each_with_index do |part, j|
1082
- next unless part == "$" && j + 1 < item.length
1083
-
1084
- begin
1085
- records << JSON.parse(item[j + 1])
1086
- rescue JSON::ParserError
1087
- nil
1088
- end
1089
- end
1090
- end
1091
- records
1172
+ parse_search_results(results)
1092
1173
  end
1093
1174
 
1094
1175
  def fetch_edges_without_search(node_id:, relation_filters:, limit:)
@@ -1115,6 +1196,65 @@ module Agentf
1115
1196
  value.to_s.gsub(/[\-{}\[\]|\\]/) { |m| "\\#{m}" }
1116
1197
  end
1117
1198
 
1199
+ def episodic_index_schema(include_vector:)
1200
+ schema = [
1201
+ "ON", "JSON",
1202
+ "PREFIX", "1", "episodic:",
1203
+ "SCHEMA",
1204
+ "$.id", "AS", "id", "TEXT",
1205
+ "$.type", "AS", "type", "TEXT",
1206
+ "$.outcome", "AS", "outcome", "TAG",
1207
+ "$.title", "AS", "title", "TEXT",
1208
+ "$.description", "AS", "description", "TEXT",
1209
+ "$.project", "AS", "project", "TAG",
1210
+ "$.context", "AS", "context", "TEXT",
1211
+ "$.code_snippet", "AS", "code_snippet", "TEXT",
1212
+ "$.created_at", "AS", "created_at", "NUMERIC",
1213
+ "$.agent", "AS", "agent", "TEXT",
1214
+ "$.related_task_id", "AS", "related_task_id", "TEXT",
1215
+ "$.metadata.intent_kind", "AS", "intent_kind", "TAG",
1216
+ "$.metadata.priority", "AS", "priority", "NUMERIC",
1217
+ "$.metadata.confidence", "AS", "confidence", "NUMERIC",
1218
+ "$.metadata.business_capability", "AS", "business_capability", "TAG",
1219
+ "$.metadata.feature_area", "AS", "feature_area", "TAG",
1220
+ "$.metadata.agent_role", "AS", "agent_role", "TAG",
1221
+ "$.metadata.division", "AS", "division", "TAG",
1222
+ "$.metadata.specialty", "AS", "specialty", "TAG",
1223
+ "$.entity_ids[*]", "AS", "entity_ids", "TAG",
1224
+ "$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
1225
+ "$.causal_from", "AS", "causal_from", "TEXT"
1226
+ ]
1227
+
1228
+ return schema unless include_vector
1229
+
1230
+ schema + [
1231
+ "$.embedding", "AS", "embedding", "VECTOR", "FLAT", "6",
1232
+ "TYPE", "FLOAT32",
1233
+ "DIM", VECTOR_DIMENSIONS.to_s,
1234
+ "DISTANCE_METRIC", "COSINE"
1235
+ ]
1236
+ end
1237
+
1238
+ def vector_search_supported?
1239
+ @search_supported && @vector_search_supported
1240
+ end
1241
+
1242
+ def normalize_vector_dimensions(vector)
1243
+ values = Array(vector).map(&:to_f).first(VECTOR_DIMENSIONS)
1244
+ values.fill(0.0, values.length...VECTOR_DIMENSIONS)
1245
+ end
1246
+
1247
+ def pack_vector(vector)
1248
+ normalize_vector_dimensions(vector).pack("e*")
1249
+ end
1250
+
1251
+ def vector_query_unsupported?(error)
1252
+ message = error.message.to_s.downcase
1253
+ return false if message.empty?
1254
+
1255
+ message.include?("vector") || message.include?("knn") || message.include?("dialect") || message.include?("syntax error")
1256
+ end
1257
+
1118
1258
  def extract_metadata_slice(metadata, keys)
1119
1259
  keys.each_with_object({}) do |key, acc|
1120
1260
  acc[key] = metadata[key] if metadata.key?(key)