agentf 0.5.0 → 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,23 @@ 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
24
30
  end
25
31
 
26
32
  # Raised when a write requires explicit user confirmation (ask_first).
@@ -54,7 +60,8 @@ module Agentf
54
60
  task_id
55
61
  end
56
62
 
57
- def store_episode(type:, title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR,
63
+ def store_episode(type:, title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR,
64
+ outcome: nil, embedding: nil,
58
65
  related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil, confirm: nil)
59
66
  # Determine persistence preference from the agent's policy boundaries.
60
67
  # Precedence: never > ask_first > always > none.
@@ -87,22 +94,28 @@ module Agentf
87
94
  metadata: metadata,
88
95
  agent: agent,
89
96
  type: type,
90
- tags: tags,
91
97
  entity_ids: entity_ids,
92
98
  relationships: relationships,
93
99
  parent_episode_id: parent_episode_id,
94
- causal_from: causal_from
100
+ causal_from: causal_from,
101
+ outcome: outcome
95
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
96
109
 
97
110
  data = {
98
111
  "id" => episode_id,
99
112
  "type" => type,
113
+ "outcome" => normalize_outcome(outcome),
100
114
  "title" => title,
101
115
  "description" => description,
102
116
  "project" => @project,
103
117
  "context" => context,
104
118
  "code_snippet" => code_snippet,
105
- "tags" => tags,
106
119
  "created_at" => Time.now.to_i,
107
120
  "agent" => agent,
108
121
  "related_task_id" => related_task_id || "",
@@ -110,7 +123,8 @@ module Agentf
110
123
  "relationships" => relationships,
111
124
  "parent_episode_id" => parent_episode_id.to_s,
112
125
  "causal_from" => causal_from.to_s,
113
- "metadata" => normalized_metadata
126
+ "metadata" => normalized_metadata,
127
+ "embedding" => embedding_vector
114
128
  }
115
129
 
116
130
  key = "episodic:#{episode_id}"
@@ -136,63 +150,34 @@ module Agentf
136
150
  related_task_id: related_task_id,
137
151
  relationships: relationships,
138
152
  metadata: normalized_metadata,
139
- tags: tags,
140
153
  agent: agent
141
154
  )
142
155
 
143
156
  episode_id
144
157
  end
145
158
 
146
- def store_success(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
159
+ def store_lesson(title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
147
160
  store_episode(
148
- type: "success",
149
- title: title,
150
- description: description,
151
- context: context,
152
- code_snippet: code_snippet,
153
- tags: tags,
154
- agent: agent,
155
- confirm: confirm
156
- )
157
- end
158
-
159
- def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
160
- store_episode(
161
- type: "pitfall",
162
- title: title,
163
- description: description,
164
- context: context,
165
- code_snippet: code_snippet,
166
- tags: tags,
167
- agent: agent,
168
- confirm: confirm
169
- )
170
- end
171
-
172
- def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
173
- store_episode(
174
- type: "lesson",
175
- title: title,
176
- description: description,
177
- context: context,
178
- code_snippet: code_snippet,
179
- tags: tags,
180
- agent: agent,
181
- confirm: confirm
182
- )
183
- 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
184
170
 
185
- 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)
186
172
  context = constraints.any? ? "Constraints: #{constraints.join('; ')}" : ""
187
173
 
188
174
  store_episode(
189
175
  type: "business_intent",
190
- title: title,
191
- description: description,
192
- context: context,
193
- tags: tags,
194
- agent: agent,
195
- confirm: confirm,
176
+ title: title,
177
+ description: description,
178
+ context: context,
179
+ agent: agent,
180
+ confirm: confirm,
196
181
  metadata: {
197
182
  "intent_kind" => "business",
198
183
  "constraints" => constraints,
@@ -201,7 +186,7 @@ module Agentf
201
186
  )
202
187
  end
203
188
 
204
- 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,
205
190
  related_task_id: nil, confirm: nil)
206
191
  context_parts = []
207
192
  context_parts << "Acceptance: #{acceptance_criteria.join('; ')}" if acceptance_criteria.any?
@@ -209,12 +194,11 @@ module Agentf
209
194
 
210
195
  store_episode(
211
196
  type: "feature_intent",
212
- title: title,
213
- description: description,
214
- context: context_parts.join(" | "),
215
- tags: tags,
216
- agent: agent,
217
- confirm: confirm,
197
+ title: title,
198
+ description: description,
199
+ context: context_parts.join(" | "),
200
+ agent: agent,
201
+ confirm: confirm,
218
202
  related_task_id: related_task_id,
219
203
  metadata: {
220
204
  "intent_kind" => "feature",
@@ -224,14 +208,13 @@ module Agentf
224
208
  )
225
209
  end
226
210
 
227
- def store_incident(title:, description:, root_cause: "", resolution: "", tags: [], agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
211
+ def store_incident(title:, description:, root_cause: "", resolution: "", agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
228
212
  business_capability: nil, confirm: nil)
229
213
  store_episode(
230
214
  type: "incident",
231
215
  title: title,
232
216
  description: description,
233
217
  context: ["Root cause: #{root_cause}", "Resolution: #{resolution}"].reject { |entry| entry.end_with?(": ") }.join(" | "),
234
- tags: tags,
235
218
  agent: agent,
236
219
  confirm: confirm,
237
220
  metadata: {
@@ -243,13 +226,12 @@ module Agentf
243
226
  )
244
227
  end
245
228
 
246
- def store_playbook(title:, description:, steps: [], tags: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil, confirm: nil)
229
+ def store_playbook(title:, description:, steps: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil, confirm: nil)
247
230
  store_episode(
248
231
  type: "playbook",
249
232
  title: title,
250
233
  description: description,
251
234
  context: steps.any? ? "Steps: #{steps.join('; ')}" : "",
252
- tags: tags,
253
235
  agent: agent,
254
236
  confirm: confirm,
255
237
  metadata: {
@@ -312,13 +294,18 @@ module Agentf
312
294
  end
313
295
 
314
296
  def get_relevant_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
315
- 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)
316
298
  end
317
299
 
318
- 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)
319
301
  profile = context_profile(agent)
320
- candidates = get_recent_memories(limit: [limit * 8, 200].min)
321
- 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)
322
309
 
323
310
  {
324
311
  "agent" => agent,
@@ -329,12 +316,11 @@ module Agentf
329
316
  }
330
317
  end
331
318
 
332
- def get_pitfalls(limit: 10)
333
- if @search_supported
334
- search_episodic(query: "@type:pitfall @project:{#{@project}}", limit: limit)
335
- else
336
- fetch_memories_without_search(limit: [limit * 4, 100].min).select { |mem| mem["type"] == "pitfall" }.first(limit)
337
- 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)
338
324
  end
339
325
 
340
326
  def get_recent_memories(limit: 10)
@@ -345,16 +331,6 @@ module Agentf
345
331
  end
346
332
  end
347
333
 
348
- def get_all_tags
349
- memories = get_recent_memories(limit: 100)
350
- all_tags = Set.new
351
- memories.each do |mem|
352
- tags = mem["tags"]
353
- all_tags.merge(tags) if tags.is_a?(Array)
354
- end
355
- all_tags.to_a
356
- end
357
-
358
334
  def delete_memory_by_id(id:, scope: "project", dry_run: false)
359
335
  normalized_scope = normalize_scope(scope)
360
336
  episode_id = normalize_episode_id(id)
@@ -417,7 +393,7 @@ module Agentf
417
393
  )
418
394
  end
419
395
 
420
- 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: {})
421
397
  edge_id = "edge_#{SecureRandom.hex(5)}"
422
398
  data = {
423
399
  "id" => edge_id,
@@ -425,7 +401,6 @@ module Agentf
425
401
  "target_id" => target_id,
426
402
  "relation" => relation,
427
403
  "weight" => weight.to_f,
428
- "tags" => tags,
429
404
  "project" => @project,
430
405
  "agent" => agent,
431
406
  "metadata" => metadata,
@@ -477,34 +452,12 @@ module Agentf
477
452
  end
478
453
 
479
454
  def create_episodic_index
480
- @client.call(
481
- "FT.CREATE", "episodic:logs",
482
- "ON", "JSON",
483
- "PREFIX", "1", "episodic:",
484
- "SCHEMA",
485
- "$.id", "AS", "id", "TEXT",
486
- "$.type", "AS", "type", "TEXT",
487
- "$.title", "AS", "title", "TEXT",
488
- "$.description", "AS", "description", "TEXT",
489
- "$.project", "AS", "project", "TAG",
490
- "$.context", "AS", "context", "TEXT",
491
- "$.code_snippet", "AS", "code_snippet", "TEXT",
492
- "$.tags", "AS", "tags", "TAG",
493
- "$.created_at", "AS", "created_at", "NUMERIC",
494
- "$.agent", "AS", "agent", "TEXT",
495
- "$.related_task_id", "AS", "related_task_id", "TEXT",
496
- "$.metadata.intent_kind", "AS", "intent_kind", "TAG",
497
- "$.metadata.priority", "AS", "priority", "NUMERIC",
498
- "$.metadata.confidence", "AS", "confidence", "NUMERIC",
499
- "$.metadata.business_capability", "AS", "business_capability", "TAG",
500
- "$.metadata.feature_area", "AS", "feature_area", "TAG",
501
- "$.metadata.agent_role", "AS", "agent_role", "TAG",
502
- "$.metadata.division", "AS", "division", "TAG",
503
- "$.metadata.specialty", "AS", "specialty", "TAG",
504
- "$.entity_ids[*]", "AS", "entity_ids", "TAG",
505
- "$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
506
- "$.causal_from", "AS", "causal_from", "TEXT"
507
- )
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))
508
461
  end
509
462
 
510
463
  def create_edge_index
@@ -520,38 +473,19 @@ module Agentf
520
473
  "$.project", "AS", "project", "TAG",
521
474
  "$.agent", "AS", "agent", "TAG",
522
475
  "$.weight", "AS", "weight", "NUMERIC",
523
- "$.created_at", "AS", "created_at", "NUMERIC",
524
- "$.tags", "AS", "tags", "TAG"
476
+ "$.created_at", "AS", "created_at", "NUMERIC"
525
477
  )
526
478
  end
527
479
 
528
480
  def search_episodic(query:, limit:)
529
481
  results = @client.call(
530
- "FT.SEARCH", "episodic:logs",
482
+ "FT.SEARCH", EPISODIC_INDEX,
531
483
  query,
532
484
  "SORTBY", "created_at", "DESC",
533
485
  "LIMIT", "0", limit.to_s
534
486
  )
535
487
 
536
- return [] unless results && results[0] > 0
537
-
538
- memories = []
539
- (2...results.length).step(2) do |i|
540
- item = results[i]
541
- if item.is_a?(Array)
542
- item.each_with_index do |part, j|
543
- if part == "$" && j + 1 < item.length
544
- begin
545
- memory = JSON.parse(item[j + 1])
546
- memories << memory
547
- rescue JSON::ParserError
548
- # Skip invalid JSON
549
- end
550
- end
551
- end
552
- end
553
- end
554
- memories
488
+ parse_search_results(results)
555
489
  end
556
490
 
557
491
  def index_already_exists?(error)
@@ -585,7 +519,7 @@ module Agentf
585
519
  end
586
520
 
587
521
  def detect_search_support
588
- @client.call("FT.INFO", "episodic:logs")
522
+ @client.call("FT.INFO", EPISODIC_INDEX)
589
523
  true
590
524
  rescue Redis::CommandError => e
591
525
  return true if index_missing_error?(e)
@@ -594,6 +528,28 @@ module Agentf
594
528
  raise Redis::CommandError, "Failed to check RediSearch availability: #{e.message}"
595
529
  end
596
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
+
597
553
  def index_missing_error?(error)
598
554
  message = error.message
599
555
  return false unless message
@@ -634,21 +590,21 @@ module Agentf
634
590
  def context_profile(agent)
635
591
  case agent.to_s.upcase
636
592
  when Agentf::AgentRoles::PLANNER
637
- { "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 }
638
594
  when Agentf::AgentRoles::ENGINEER
639
- { "preferred_types" => %w[playbook success lesson pitfall], "pitfall_penalty" => 0.05 }
595
+ { "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
640
596
  when Agentf::AgentRoles::QA_TESTER
641
- { "preferred_types" => %w[lesson pitfall incident success], "pitfall_penalty" => 0.0 }
597
+ { "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
642
598
  when Agentf::AgentRoles::INCIDENT_RESPONDER
643
- { "preferred_types" => %w[incident pitfall lesson], "pitfall_penalty" => 0.0 }
599
+ { "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
644
600
  when Agentf::AgentRoles::SECURITY_REVIEWER
645
- { "preferred_types" => %w[pitfall lesson incident], "pitfall_penalty" => 0.0 }
601
+ { "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
646
602
  else
647
- { "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 }
648
604
  end
649
605
  end
650
606
 
651
- def rank_memories(candidates:, agent:, profile:)
607
+ def rank_memories(candidates:, agent:, profile:, query_embedding: nil)
652
608
  now = Time.now.to_i
653
609
  preferred_types = Array(profile["preferred_types"])
654
610
 
@@ -660,19 +616,41 @@ module Agentf
660
616
  confidence = metadata.fetch("confidence", 0.6).to_f
661
617
  confidence = 0.0 if confidence.negative?
662
618
  confidence = 1.0 if confidence > 1.0
619
+ semantic_score = cosine_similarity(query_embedding, parse_embedding(memory["embedding"]))
663
620
 
664
621
  type_score = preferred_types.include?(type) ? 1.0 : 0.25
665
622
  agent_score = (memory["agent"] == agent || memory["agent"] == Agentf::AgentRoles::ORCHESTRATOR) ? 1.0 : 0.2
666
623
  age_seconds = [now - memory.fetch("created_at", now).to_i, 0].max
667
624
  recency_score = 1.0 / (1.0 + (age_seconds / 86_400.0))
668
625
 
669
- pitfall_penalty = type == "pitfall" ? profile.fetch("pitfall_penalty", 0.0).to_f : 0.0
670
- 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)
671
628
  memory
672
629
  end
673
630
  .sort_by { |memory| -memory["rank_score"] }
674
631
  end
675
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
+
676
654
  def load_episode(key)
677
655
  raw = if @json_supported
678
656
  begin
@@ -707,6 +685,37 @@ module Agentf
707
685
  []
708
686
  end
709
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
+
710
719
  def cosine_similarity(a, b)
711
720
  return 0.0 if a.empty? || b.empty? || a.length != b.length
712
721
 
@@ -754,6 +763,53 @@ module Agentf
754
763
  memories
755
764
  end
756
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
+
757
813
  def collect_related_edge_keys(episode_ids:, scope:)
758
814
  ids = episode_ids.map(&:to_s).reject(&:empty?).to_set
759
815
  return [] if ids.empty?
@@ -850,9 +906,9 @@ module Agentf
850
906
  }
851
907
  end
852
908
 
853
- 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:)
854
910
  if related_task_id && !related_task_id.to_s.strip.empty?
855
- 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)
856
912
  end
857
913
 
858
914
  Array(relationships).each do |relation|
@@ -867,7 +923,6 @@ module Agentf
867
923
  target_id: target,
868
924
  relation: relation_type,
869
925
  weight: (relation["weight"] || relation[:weight] || 1.0).to_f,
870
- tags: tags,
871
926
  agent: agent,
872
927
  metadata: { "source_metadata" => extract_metadata_slice(metadata, %w[intent_kind agent_role division]) }
873
928
  )
@@ -875,32 +930,82 @@ module Agentf
875
930
 
876
931
  parent = metadata["parent_episode_id"].to_s
877
932
  unless parent.empty?
878
- 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)
879
934
  end
880
935
 
881
936
  causal_from = metadata["causal_from"].to_s
882
937
  unless causal_from.empty?
883
- 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)
884
939
  end
885
940
  rescue StandardError
886
941
  nil
887
942
  end
888
943
 
889
- 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:)
890
945
  base = metadata.is_a?(Hash) ? metadata.dup : {}
891
946
  base["agent_role"] = agent
892
947
  base["division"] = infer_division(agent)
893
948
  base["specialty"] = infer_specialty(agent)
894
949
  base["capabilities"] = infer_capabilities(agent)
895
950
  base["episode_type"] = type
896
- base["tag_count"] = Array(tags).length
897
951
  base["relationship_count"] = Array(relationships).length
898
952
  base["entity_ids"] = Array(entity_ids)
953
+ base["outcome"] = normalize_outcome(outcome) if outcome
899
954
  base["parent_episode_id"] = parent_episode_id.to_s unless parent_episode_id.to_s.empty?
900
955
  base["causal_from"] = causal_from.to_s unless causal_from.to_s.empty?
901
956
  base
902
957
  end
903
958
 
959
+ def normalized_query_embedding(query_embedding:, query_text:)
960
+ embedded = parse_embedding(query_embedding)
961
+ return embedded if embedded.any?
962
+
963
+ embed_text(query_text)
964
+ end
965
+
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")
978
+ end
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
+
904
1009
  # NOTE: previous implementations exposed an `agent_requires_confirmation?`
905
1010
  # helper here. That functionality is superseded by
906
1011
  # `persistence_preference_for(agent)` which returns explicit preferences
@@ -1064,24 +1169,7 @@ module Agentf
1064
1169
  "SORTBY", "created_at", "DESC",
1065
1170
  "LIMIT", "0", limit.to_s
1066
1171
  )
1067
- return [] unless results && results[0] > 0
1068
-
1069
- records = []
1070
- (2...results.length).step(2) do |i|
1071
- item = results[i]
1072
- next unless item.is_a?(Array)
1073
-
1074
- item.each_with_index do |part, j|
1075
- next unless part == "$" && j + 1 < item.length
1076
-
1077
- begin
1078
- records << JSON.parse(item[j + 1])
1079
- rescue JSON::ParserError
1080
- nil
1081
- end
1082
- end
1083
- end
1084
- records
1172
+ parse_search_results(results)
1085
1173
  end
1086
1174
 
1087
1175
  def fetch_edges_without_search(node_id:, relation_filters:, limit:)
@@ -1108,6 +1196,65 @@ module Agentf
1108
1196
  value.to_s.gsub(/[\-{}\[\]|\\]/) { |m| "\\#{m}" }
1109
1197
  end
1110
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
+
1111
1258
  def extract_metadata_slice(metadata, keys)
1112
1259
  keys.each_with_object({}) do |key, acc|
1113
1260
  acc[key] = metadata[key] if metadata.key?(key)