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.
- checksums.yaml +4 -4
- data/lib/agentf/agents/architect.rb +3 -3
- data/lib/agentf/agents/base.rb +2 -2
- data/lib/agentf/agents/debugger.rb +1 -2
- data/lib/agentf/agents/designer.rb +6 -5
- data/lib/agentf/agents/documenter.rb +2 -2
- data/lib/agentf/agents/explorer.rb +1 -2
- data/lib/agentf/agents/reviewer.rb +7 -7
- data/lib/agentf/agents/security.rb +11 -9
- data/lib/agentf/agents/specialist.rb +11 -9
- data/lib/agentf/agents/tester.rb +6 -5
- data/lib/agentf/cli/eval.rb +1 -1
- data/lib/agentf/cli/memory.rb +66 -70
- data/lib/agentf/cli/router.rb +1 -1
- data/lib/agentf/commands/memory_reviewer.rb +16 -50
- data/lib/agentf/commands/metrics.rb +4 -13
- data/lib/agentf/context_builder.rb +4 -14
- data/lib/agentf/embedding_provider.rb +35 -0
- data/lib/agentf/installer.rb +73 -78
- data/lib/agentf/mcp/server.rb +40 -102
- data/lib/agentf/memory.rb +316 -169
- data/lib/agentf/version.rb +1 -1
- data/lib/agentf/workflow_engine.rb +15 -18
- data/lib/agentf.rb +1 -0
- metadata +3 -2
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: "",
|
|
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
|
|
159
|
+
def store_lesson(title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
|
|
147
160
|
store_episode(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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: [],
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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: [],
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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: "",
|
|
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: [],
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
|
595
|
+
{ "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
|
|
640
596
|
when Agentf::AgentRoles::QA_TESTER
|
|
641
|
-
{ "preferred_types" => %w[lesson
|
|
597
|
+
{ "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
|
|
642
598
|
when Agentf::AgentRoles::INCIDENT_RESPONDER
|
|
643
|
-
{ "preferred_types" => %w[incident
|
|
599
|
+
{ "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
|
|
644
600
|
when Agentf::AgentRoles::SECURITY_REVIEWER
|
|
645
|
-
{ "preferred_types" => %w[
|
|
601
|
+
{ "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
|
|
646
602
|
else
|
|
647
|
-
{ "preferred_types" => %w[lesson
|
|
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
|
-
|
|
670
|
-
memory["rank_score"] = ((0.
|
|
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:,
|
|
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",
|
|
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",
|
|
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",
|
|
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:,
|
|
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
|
-
|
|
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)
|