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.
- checksums.yaml +4 -4
- data/lib/agentf/agents/architect.rb +3 -3
- data/lib/agentf/agents/base.rb +8 -23
- data/lib/agentf/agents/debugger.rb +1 -2
- data/lib/agentf/agents/designer.rb +22 -7
- 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 +28 -12
- data/lib/agentf/agents/tester.rb +22 -7
- data/lib/agentf/cli/eval.rb +1 -1
- data/lib/agentf/cli/memory.rb +95 -92
- data/lib/agentf/cli/router.rb +1 -1
- data/lib/agentf/commands/memory_reviewer.rb +21 -55
- 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 +162 -82
- data/lib/agentf/mcp/server.rb +123 -177
- data/lib/agentf/memory/confirmation_handler.rb +24 -0
- data/lib/agentf/memory.rb +322 -169
- data/lib/agentf/version.rb +1 -1
- data/lib/agentf/workflow_engine.rb +15 -18
- data/lib/agentf.rb +2 -0
- metadata +4 -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: {
|
|
@@ -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
|
-
|
|
321
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
)
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
|
601
|
+
{ "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
|
|
640
602
|
when Agentf::AgentRoles::QA_TESTER
|
|
641
|
-
{ "preferred_types" => %w[lesson
|
|
603
|
+
{ "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
|
|
642
604
|
when Agentf::AgentRoles::INCIDENT_RESPONDER
|
|
643
|
-
{ "preferred_types" => %w[incident
|
|
605
|
+
{ "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
|
|
644
606
|
when Agentf::AgentRoles::SECURITY_REVIEWER
|
|
645
|
-
{ "preferred_types" => %w[
|
|
607
|
+
{ "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
|
|
646
608
|
else
|
|
647
|
-
{ "preferred_types" => %w[lesson
|
|
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
|
-
|
|
670
|
-
memory["rank_score"] = ((0.
|
|
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:,
|
|
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",
|
|
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",
|
|
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",
|
|
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:,
|
|
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
|
-
|
|
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)
|