agentf 0.5.0 → 0.7.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: {
@@ -300,6 +282,12 @@ module Agentf
300
282
  end
301
283
  end
302
284
 
285
+ def get_memories_by_agent(agent:, limit: 10)
286
+ collect_episode_records(scope: "project", agent: agent)
287
+ .sort_by { |mem| -(mem["created_at"] || 0) }
288
+ .first(limit)
289
+ end
290
+
303
291
  def get_intents(kind: nil, limit: 10)
304
292
  return get_memories_by_type(type: "business_intent", limit: limit) if kind == "business"
305
293
  return get_memories_by_type(type: "feature_intent", limit: limit) if kind == "feature"
@@ -312,13 +300,18 @@ module Agentf
312
300
  end
313
301
 
314
302
  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)
303
+ get_agent_context(agent: agent, query_embedding: query_embedding, query_text: nil, task_type: task_type, limit: limit)
316
304
  end
317
305
 
318
- def get_agent_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
306
+ def get_agent_context(agent:, query_embedding: nil, query_text: nil, task_type: nil, limit: 8)
319
307
  profile = context_profile(agent)
320
- candidates = get_recent_memories(limit: [limit * 8, 200].min)
321
- ranked = rank_memories(candidates: candidates, agent: agent, profile: profile)
308
+ query_vector = normalized_query_embedding(query_embedding: query_embedding, query_text: query_text)
309
+ candidates = if vector_search_supported? && query_vector.any?
310
+ vector_search_candidates(query_embedding: query_vector, limit: DEFAULT_SEMANTIC_SCAN_LIMIT)
311
+ else
312
+ collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
313
+ end
314
+ ranked = rank_memories(candidates: candidates, agent: agent, profile: profile, query_embedding: query_vector)
322
315
 
323
316
  {
324
317
  "agent" => agent,
@@ -329,12 +322,11 @@ module Agentf
329
322
  }
330
323
  end
331
324
 
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
325
+ def get_episodes(limit: 10, outcome: nil)
326
+ memories = fetch_memories_without_search(limit: [limit * 8, DEFAULT_SEMANTIC_SCAN_LIMIT].min)
327
+ memories = memories.select { |mem| mem["type"] == "episode" }
328
+ memories = memories.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
329
+ memories.first(limit)
338
330
  end
339
331
 
340
332
  def get_recent_memories(limit: 10)
@@ -345,16 +337,6 @@ module Agentf
345
337
  end
346
338
  end
347
339
 
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
340
  def delete_memory_by_id(id:, scope: "project", dry_run: false)
359
341
  normalized_scope = normalize_scope(scope)
360
342
  episode_id = normalize_episode_id(id)
@@ -417,7 +399,7 @@ module Agentf
417
399
  )
418
400
  end
419
401
 
420
- def store_edge(source_id:, target_id:, relation:, weight: 1.0, tags: [], agent: Agentf::AgentRoles::ORCHESTRATOR, metadata: {})
402
+ def store_edge(source_id:, target_id:, relation:, weight: 1.0, agent: Agentf::AgentRoles::ORCHESTRATOR, metadata: {})
421
403
  edge_id = "edge_#{SecureRandom.hex(5)}"
422
404
  data = {
423
405
  "id" => edge_id,
@@ -425,7 +407,6 @@ module Agentf
425
407
  "target_id" => target_id,
426
408
  "relation" => relation,
427
409
  "weight" => weight.to_f,
428
- "tags" => tags,
429
410
  "project" => @project,
430
411
  "agent" => agent,
431
412
  "metadata" => metadata,
@@ -477,34 +458,12 @@ module Agentf
477
458
  end
478
459
 
479
460
  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
- )
461
+ @client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: true))
462
+ rescue Redis::CommandError => e
463
+ raise if index_already_exists?(e)
464
+ raise unless vector_query_unsupported?(e)
465
+
466
+ @client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: false))
508
467
  end
509
468
 
510
469
  def create_edge_index
@@ -520,38 +479,19 @@ module Agentf
520
479
  "$.project", "AS", "project", "TAG",
521
480
  "$.agent", "AS", "agent", "TAG",
522
481
  "$.weight", "AS", "weight", "NUMERIC",
523
- "$.created_at", "AS", "created_at", "NUMERIC",
524
- "$.tags", "AS", "tags", "TAG"
482
+ "$.created_at", "AS", "created_at", "NUMERIC"
525
483
  )
526
484
  end
527
485
 
528
486
  def search_episodic(query:, limit:)
529
487
  results = @client.call(
530
- "FT.SEARCH", "episodic:logs",
488
+ "FT.SEARCH", EPISODIC_INDEX,
531
489
  query,
532
490
  "SORTBY", "created_at", "DESC",
533
491
  "LIMIT", "0", limit.to_s
534
492
  )
535
493
 
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
494
+ parse_search_results(results)
555
495
  end
556
496
 
557
497
  def index_already_exists?(error)
@@ -585,7 +525,7 @@ module Agentf
585
525
  end
586
526
 
587
527
  def detect_search_support
588
- @client.call("FT.INFO", "episodic:logs")
528
+ @client.call("FT.INFO", EPISODIC_INDEX)
589
529
  true
590
530
  rescue Redis::CommandError => e
591
531
  return true if index_missing_error?(e)
@@ -594,6 +534,28 @@ module Agentf
594
534
  raise Redis::CommandError, "Failed to check RediSearch availability: #{e.message}"
595
535
  end
596
536
 
537
+ def detect_vector_search_support
538
+ return false unless @search_supported
539
+
540
+ info = @client.call("FT.INFO", EPISODIC_INDEX)
541
+ return false unless info.to_s.upcase.include?("VECTOR")
542
+
543
+ @client.call(
544
+ "FT.SEARCH", EPISODIC_INDEX,
545
+ "*=>[KNN 1 @embedding $query_vector AS vector_distance]",
546
+ "PARAMS", "2", "query_vector", pack_vector(Array.new(VECTOR_DIMENSIONS, 0.0)),
547
+ "SORTBY", "vector_distance", "ASC",
548
+ "RETURN", "2", "$", "vector_distance",
549
+ "DIALECT", "2",
550
+ "LIMIT", "0", "1"
551
+ )
552
+ true
553
+ rescue Redis::CommandError => e
554
+ return false if index_missing_error?(e) || vector_query_unsupported?(e)
555
+
556
+ raise Redis::CommandError, "Failed to check Redis vector search availability: #{e.message}"
557
+ end
558
+
597
559
  def index_missing_error?(error)
598
560
  message = error.message
599
561
  return false unless message
@@ -634,21 +596,21 @@ module Agentf
634
596
  def context_profile(agent)
635
597
  case agent.to_s.upcase
636
598
  when Agentf::AgentRoles::PLANNER
637
- { "preferred_types" => %w[business_intent feature_intent lesson playbook pitfall], "pitfall_penalty" => 0.1 }
599
+ { "preferred_types" => %w[business_intent feature_intent lesson playbook episode], "negative_outcome_penalty" => 0.1 }
638
600
  when Agentf::AgentRoles::ENGINEER
639
- { "preferred_types" => %w[playbook success lesson pitfall], "pitfall_penalty" => 0.05 }
601
+ { "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
640
602
  when Agentf::AgentRoles::QA_TESTER
641
- { "preferred_types" => %w[lesson pitfall incident success], "pitfall_penalty" => 0.0 }
603
+ { "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
642
604
  when Agentf::AgentRoles::INCIDENT_RESPONDER
643
- { "preferred_types" => %w[incident pitfall lesson], "pitfall_penalty" => 0.0 }
605
+ { "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
644
606
  when Agentf::AgentRoles::SECURITY_REVIEWER
645
- { "preferred_types" => %w[pitfall lesson incident], "pitfall_penalty" => 0.0 }
607
+ { "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
646
608
  else
647
- { "preferred_types" => %w[lesson pitfall success business_intent feature_intent], "pitfall_penalty" => 0.05 }
609
+ { "preferred_types" => %w[lesson episode business_intent feature_intent], "negative_outcome_penalty" => 0.05 }
648
610
  end
649
611
  end
650
612
 
651
- def rank_memories(candidates:, agent:, profile:)
613
+ def rank_memories(candidates:, agent:, profile:, query_embedding: nil)
652
614
  now = Time.now.to_i
653
615
  preferred_types = Array(profile["preferred_types"])
654
616
 
@@ -660,19 +622,41 @@ module Agentf
660
622
  confidence = metadata.fetch("confidence", 0.6).to_f
661
623
  confidence = 0.0 if confidence.negative?
662
624
  confidence = 1.0 if confidence > 1.0
625
+ semantic_score = cosine_similarity(query_embedding, parse_embedding(memory["embedding"]))
663
626
 
664
627
  type_score = preferred_types.include?(type) ? 1.0 : 0.25
665
628
  agent_score = (memory["agent"] == agent || memory["agent"] == Agentf::AgentRoles::ORCHESTRATOR) ? 1.0 : 0.2
666
629
  age_seconds = [now - memory.fetch("created_at", now).to_i, 0].max
667
630
  recency_score = 1.0 / (1.0 + (age_seconds / 86_400.0))
668
631
 
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)
632
+ negative_outcome_penalty = memory["outcome"] == "negative" ? profile.fetch("negative_outcome_penalty", 0.0).to_f : 0.0
633
+ 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
634
  memory
672
635
  end
673
636
  .sort_by { |memory| -memory["rank_score"] }
674
637
  end
675
638
 
639
+ public def search_memories(query:, limit: 10, type: nil, agent: nil, outcome: nil)
640
+ query_vector = embed_text(query)
641
+ candidates = if vector_search_supported? && query_vector.any?
642
+ native = vector_search_episodes(query_embedding: query_vector, limit: limit, type: type, agent: agent, outcome: outcome)
643
+ native.empty? ? collect_episode_records(scope: "project", type: type, agent: agent) : native
644
+ else
645
+ collect_episode_records(scope: "project", type: type, agent: agent)
646
+ end
647
+ candidates = candidates.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
648
+
649
+ ranked = candidates.map do |memory|
650
+ score = cosine_similarity(query_vector, parse_embedding(memory["embedding"]))
651
+ lexical = lexical_overlap_score(query, memory)
652
+ next if score <= 0 && lexical <= 0
653
+
654
+ memory.merge("score" => ((0.75 * score) + (0.25 * lexical)).round(6))
655
+ end.compact
656
+
657
+ ranked.sort_by { |memory| -memory["score"] }.first(limit)
658
+ end
659
+
676
660
  def load_episode(key)
677
661
  raw = if @json_supported
678
662
  begin
@@ -707,6 +691,37 @@ module Agentf
707
691
  []
708
692
  end
709
693
 
694
+ def parse_search_results(results)
695
+ return [] unless results && results[0].to_i.positive?
696
+
697
+ records = []
698
+ (2...results.length).step(2) do |i|
699
+ item = results[i]
700
+ next unless item.is_a?(Array)
701
+
702
+ record = {}
703
+ item.each_slice(2) do |field, value|
704
+ next if value.nil?
705
+
706
+ if field == "$"
707
+ begin
708
+ payload = JSON.parse(value)
709
+ record.merge!(payload) if payload.is_a?(Hash)
710
+ rescue JSON::ParserError
711
+ record = nil
712
+ break
713
+ end
714
+ else
715
+ record[field] = value
716
+ end
717
+ end
718
+
719
+ records << record if record.is_a?(Hash) && record.any?
720
+ end
721
+
722
+ records
723
+ end
724
+
710
725
  def cosine_similarity(a, b)
711
726
  return 0.0 if a.empty? || b.empty? || a.length != b.length
712
727
 
@@ -754,6 +769,53 @@ module Agentf
754
769
  memories
755
770
  end
756
771
 
772
+ def vector_search_episodes(query_embedding:, limit:, type: nil, agent: nil, outcome: nil)
773
+ return [] unless vector_search_supported?
774
+
775
+ requested_limit = [limit.to_i, 1].max
776
+ search_limit = [requested_limit * 4, 10].max
777
+ filters = ["@project:{#{escape_tag(@project)}}"]
778
+ normalized_outcome = normalize_outcome(outcome)
779
+ filters << "@outcome:{#{escape_tag(normalized_outcome)}}" if normalized_outcome
780
+ base_query = filters.join(" ")
781
+
782
+ results = @client.call(
783
+ "FT.SEARCH", EPISODIC_INDEX,
784
+ "#{base_query}=>[KNN #{search_limit} @embedding $query_vector AS vector_distance]",
785
+ "PARAMS", "2", "query_vector", pack_vector(query_embedding),
786
+ "SORTBY", "vector_distance", "ASC",
787
+ "RETURN", "2", "$", "vector_distance",
788
+ "DIALECT", "2",
789
+ "LIMIT", "0", search_limit.to_s
790
+ )
791
+
792
+ parse_search_results(results)
793
+ .select do |memory|
794
+ next false unless memory["project"].to_s == @project.to_s
795
+ next false unless type.to_s.empty? || memory["type"].to_s == type.to_s
796
+ next false unless agent.to_s.empty? || memory["agent"].to_s == agent.to_s
797
+ next false unless normalized_outcome.nil? || memory["outcome"].to_s == normalized_outcome
798
+
799
+ true
800
+ end
801
+ .each { |memory| memory["vector_distance"] = memory["vector_distance"].to_f if memory.key?("vector_distance") }
802
+ .first(requested_limit)
803
+ rescue Redis::CommandError => e
804
+ if vector_query_unsupported?(e)
805
+ @vector_search_supported = false
806
+ return []
807
+ end
808
+
809
+ raise
810
+ end
811
+
812
+ def vector_search_candidates(query_embedding:, limit:)
813
+ native = vector_search_episodes(query_embedding: query_embedding, limit: limit)
814
+ return native if native.any?
815
+
816
+ collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
817
+ end
818
+
757
819
  def collect_related_edge_keys(episode_ids:, scope:)
758
820
  ids = episode_ids.map(&:to_s).reject(&:empty?).to_set
759
821
  return [] if ids.empty?
@@ -850,9 +912,9 @@ module Agentf
850
912
  }
851
913
  end
852
914
 
853
- def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:, tags:, agent:)
915
+ def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:, agent:)
854
916
  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)
917
+ store_edge(source_id: episode_id, target_id: related_task_id, relation: "relates_to", agent: agent)
856
918
  end
857
919
 
858
920
  Array(relationships).each do |relation|
@@ -867,7 +929,6 @@ module Agentf
867
929
  target_id: target,
868
930
  relation: relation_type,
869
931
  weight: (relation["weight"] || relation[:weight] || 1.0).to_f,
870
- tags: tags,
871
932
  agent: agent,
872
933
  metadata: { "source_metadata" => extract_metadata_slice(metadata, %w[intent_kind agent_role division]) }
873
934
  )
@@ -875,32 +936,82 @@ module Agentf
875
936
 
876
937
  parent = metadata["parent_episode_id"].to_s
877
938
  unless parent.empty?
878
- store_edge(source_id: episode_id, target_id: parent, relation: "child_of", tags: tags, agent: agent)
939
+ store_edge(source_id: episode_id, target_id: parent, relation: "child_of", agent: agent)
879
940
  end
880
941
 
881
942
  causal_from = metadata["causal_from"].to_s
882
943
  unless causal_from.empty?
883
- store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by", tags: tags, agent: agent)
944
+ store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by", agent: agent)
884
945
  end
885
946
  rescue StandardError
886
947
  nil
887
948
  end
888
949
 
889
- def enrich_metadata(metadata:, agent:, type:, tags:, entity_ids:, relationships:, parent_episode_id:, causal_from:)
950
+ def enrich_metadata(metadata:, agent:, type:, entity_ids:, relationships:, parent_episode_id:, causal_from:, outcome:)
890
951
  base = metadata.is_a?(Hash) ? metadata.dup : {}
891
952
  base["agent_role"] = agent
892
953
  base["division"] = infer_division(agent)
893
954
  base["specialty"] = infer_specialty(agent)
894
955
  base["capabilities"] = infer_capabilities(agent)
895
956
  base["episode_type"] = type
896
- base["tag_count"] = Array(tags).length
897
957
  base["relationship_count"] = Array(relationships).length
898
958
  base["entity_ids"] = Array(entity_ids)
959
+ base["outcome"] = normalize_outcome(outcome) if outcome
899
960
  base["parent_episode_id"] = parent_episode_id.to_s unless parent_episode_id.to_s.empty?
900
961
  base["causal_from"] = causal_from.to_s unless causal_from.to_s.empty?
901
962
  base
902
963
  end
903
964
 
965
+ def normalized_query_embedding(query_embedding:, query_text:)
966
+ embedded = parse_embedding(query_embedding)
967
+ return embedded if embedded.any?
968
+
969
+ embed_text(query_text)
970
+ end
971
+
972
+ def episode_embedding_text(title:, description:, context:, code_snippet:, metadata:)
973
+ [
974
+ title,
975
+ description,
976
+ context,
977
+ code_snippet,
978
+ metadata["feature_area"],
979
+ metadata["business_capability"],
980
+ metadata["intent_kind"],
981
+ metadata["resolution"],
982
+ metadata["root_cause"]
983
+ ].compact.join("\n")
984
+ end
985
+
986
+ def lexical_overlap_score(query, memory)
987
+ query_tokens = normalize_tokens(query)
988
+ return 0.0 if query_tokens.empty?
989
+
990
+ memory_tokens = normalize_tokens([memory["title"], memory["description"], memory["context"], memory["code_snippet"]].compact.join(" "))
991
+ return 0.0 if memory_tokens.empty?
992
+
993
+ overlap = (query_tokens & memory_tokens).length
994
+ overlap.to_f / query_tokens.length.to_f
995
+ end
996
+
997
+ def normalize_tokens(text)
998
+ text.to_s.downcase.scan(/[a-z0-9_]+/).reject { |token| token.length < 2 }
999
+ end
1000
+
1001
+ def embed_text(text)
1002
+ @embedding_provider.embed(text)
1003
+ end
1004
+
1005
+ def normalize_outcome(value)
1006
+ normalized = value.to_s.strip.downcase
1007
+ return nil if normalized.empty?
1008
+ return "positive" if %w[positive success succeeded passed pass completed approved].include?(normalized)
1009
+ return "negative" if %w[negative failure failed fail pitfall error blocked violated].include?(normalized)
1010
+ return "neutral" if %w[neutral info informational lesson observation].include?(normalized)
1011
+
1012
+ normalized
1013
+ end
1014
+
904
1015
  # NOTE: previous implementations exposed an `agent_requires_confirmation?`
905
1016
  # helper here. That functionality is superseded by
906
1017
  # `persistence_preference_for(agent)` which returns explicit preferences
@@ -1064,24 +1175,7 @@ module Agentf
1064
1175
  "SORTBY", "created_at", "DESC",
1065
1176
  "LIMIT", "0", limit.to_s
1066
1177
  )
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
1178
+ parse_search_results(results)
1085
1179
  end
1086
1180
 
1087
1181
  def fetch_edges_without_search(node_id:, relation_filters:, limit:)
@@ -1108,6 +1202,65 @@ module Agentf
1108
1202
  value.to_s.gsub(/[\-{}\[\]|\\]/) { |m| "\\#{m}" }
1109
1203
  end
1110
1204
 
1205
+ def episodic_index_schema(include_vector:)
1206
+ schema = [
1207
+ "ON", "JSON",
1208
+ "PREFIX", "1", "episodic:",
1209
+ "SCHEMA",
1210
+ "$.id", "AS", "id", "TEXT",
1211
+ "$.type", "AS", "type", "TEXT",
1212
+ "$.outcome", "AS", "outcome", "TAG",
1213
+ "$.title", "AS", "title", "TEXT",
1214
+ "$.description", "AS", "description", "TEXT",
1215
+ "$.project", "AS", "project", "TAG",
1216
+ "$.context", "AS", "context", "TEXT",
1217
+ "$.code_snippet", "AS", "code_snippet", "TEXT",
1218
+ "$.created_at", "AS", "created_at", "NUMERIC",
1219
+ "$.agent", "AS", "agent", "TEXT",
1220
+ "$.related_task_id", "AS", "related_task_id", "TEXT",
1221
+ "$.metadata.intent_kind", "AS", "intent_kind", "TAG",
1222
+ "$.metadata.priority", "AS", "priority", "NUMERIC",
1223
+ "$.metadata.confidence", "AS", "confidence", "NUMERIC",
1224
+ "$.metadata.business_capability", "AS", "business_capability", "TAG",
1225
+ "$.metadata.feature_area", "AS", "feature_area", "TAG",
1226
+ "$.metadata.agent_role", "AS", "agent_role", "TAG",
1227
+ "$.metadata.division", "AS", "division", "TAG",
1228
+ "$.metadata.specialty", "AS", "specialty", "TAG",
1229
+ "$.entity_ids[*]", "AS", "entity_ids", "TAG",
1230
+ "$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
1231
+ "$.causal_from", "AS", "causal_from", "TEXT"
1232
+ ]
1233
+
1234
+ return schema unless include_vector
1235
+
1236
+ schema + [
1237
+ "$.embedding", "AS", "embedding", "VECTOR", "FLAT", "6",
1238
+ "TYPE", "FLOAT32",
1239
+ "DIM", VECTOR_DIMENSIONS.to_s,
1240
+ "DISTANCE_METRIC", "COSINE"
1241
+ ]
1242
+ end
1243
+
1244
+ def vector_search_supported?
1245
+ @search_supported && @vector_search_supported
1246
+ end
1247
+
1248
+ def normalize_vector_dimensions(vector)
1249
+ values = Array(vector).map(&:to_f).first(VECTOR_DIMENSIONS)
1250
+ values.fill(0.0, values.length...VECTOR_DIMENSIONS)
1251
+ end
1252
+
1253
+ def pack_vector(vector)
1254
+ normalize_vector_dimensions(vector).pack("e*")
1255
+ end
1256
+
1257
+ def vector_query_unsupported?(error)
1258
+ message = error.message.to_s.downcase
1259
+ return false if message.empty?
1260
+
1261
+ message.include?("vector") || message.include?("knn") || message.include?("dialect") || message.include?("syntax error")
1262
+ end
1263
+
1111
1264
  def extract_metadata_slice(metadata, keys)
1112
1265
  keys.each_with_object({}) do |key, acc|
1113
1266
  acc[key] = metadata[key] if metadata.key?(key)