agentf 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,8 @@ module Agentf
34
34
  scope: "all",
35
35
  global_root: Dir.home,
36
36
  local_root: Dir.pwd,
37
- force: false
37
+ force: false,
38
+ opencode_runtime: "mcp"
38
39
  }
39
40
  end
40
41
 
@@ -78,6 +79,9 @@ module Agentf
78
79
 
79
80
  local_root = parse_single_option(args, "--local-root=")
80
81
  @options[:local_root] = File.expand_path(local_root) if local_root
82
+
83
+ opencode_runtime = parse_single_option(args, "--opencode-runtime=")
84
+ @options[:opencode_runtime] = opencode_runtime if opencode_runtime
81
85
  end
82
86
 
83
87
  def roots_for(scope)
@@ -113,7 +117,8 @@ module Agentf
113
117
 
114
118
  installer = @installer_class.new(
115
119
  global_root: root,
116
- local_root: root
120
+ local_root: root,
121
+ opencode_runtime: @options[:opencode_runtime]
117
122
  )
118
123
 
119
124
  results = installer.install(
@@ -191,12 +196,14 @@ module Agentf
191
196
  --scope=SCOPE Update scope: global|local|all (default: all)
192
197
  --global-root=PATH Root for global installs (default: $HOME)
193
198
  --local-root=PATH Root for local installs (default: current directory)
199
+ --opencode-runtime=MODE Opencode runtime: mcp|plugin (default: mcp)
194
200
  --force Regenerate even if version matches
195
201
 
196
202
  Examples:
197
203
  agentf update
198
204
  agentf update --force
199
205
  agentf update --provider=opencode,copilot --scope=local
206
+ agentf update --provider=opencode --opencode-runtime=plugin
200
207
  HELP
201
208
  end
202
209
  end
@@ -12,14 +12,11 @@ module Agentf
12
12
  def self.manifest
13
13
  {
14
14
  "name" => NAME,
15
- "description" => "Review and query Redis-stored memories, pitfalls, and learnings.",
15
+ "description" => "Review and query Redis-stored memories, episodes, and learnings.",
16
16
  "commands" => [
17
17
  { "name" => "get_recent_memories", "type" => "function" },
18
- { "name" => "get_pitfalls", "type" => "function" },
18
+ { "name" => "get_episodes", "type" => "function" },
19
19
  { "name" => "get_lessons", "type" => "function" },
20
- { "name" => "get_successes", "type" => "function" },
21
- { "name" => "get_all_tags", "type" => "function" },
22
- { "name" => "get_by_tag", "type" => "function" },
23
20
  { "name" => "get_by_type", "type" => "function" },
24
21
  { "name" => "get_by_agent", "type" => "function" },
25
22
  { "name" => "search", "type" => "function" },
@@ -30,9 +27,10 @@ module Agentf
30
27
  }
31
28
  end
32
29
 
33
- def initialize(project: nil)
30
+ def initialize(project: nil, memory: nil)
34
31
  @project = project || Agentf.config.project_name
35
- @memory = Agentf::Memory::RedisMemory.new(project: @project)
32
+ # Allow injecting a memory instance for testing; default to real RedisMemory
33
+ @memory = memory || Agentf::Memory::RedisMemory.new(project: @project)
36
34
  end
37
35
 
38
36
  # Get recent memories
@@ -43,10 +41,9 @@ module Agentf
43
41
  { "error" => e.message }
44
42
  end
45
43
 
46
- # Get all pitfalls (things that went wrong)
47
- def get_pitfalls(limit: 10)
48
- pitfalls = @memory.get_pitfalls(limit: limit)
49
- format_memories(pitfalls)
44
+ def get_episodes(limit: 10, outcome: nil)
45
+ episodes = @memory.get_episodes(limit: limit, outcome: outcome)
46
+ format_memories(episodes)
50
47
  rescue => e
51
48
  { "error" => e.message }
52
49
  end
@@ -59,14 +56,6 @@ module Agentf
59
56
  { "error" => e.message }
60
57
  end
61
58
 
62
- # Get all successes
63
- def get_successes(limit: 10)
64
- successes = @memory.get_memories_by_type(type: "success", limit: limit)
65
- format_memories(successes)
66
- rescue => e
67
- { "error" => e.message }
68
- end
69
-
70
59
  def get_business_intents(limit: 10)
71
60
  intents = @memory.get_intents(kind: "business", limit: limit)
72
61
  format_memories(intents)
@@ -81,28 +70,17 @@ module Agentf
81
70
  { "error" => e.message }
82
71
  end
83
72
 
84
- # Get all unique tags from memories
85
- def get_all_tags
86
- tags = @memory.get_all_tags
87
- { "tags" => tags.sort, "count" => tags.length }
88
- rescue => e
89
- { "error" => e.message }
90
- end
91
-
92
- # Get memories by tag
93
- def get_by_tag(tag, limit: 10)
94
- memories = @memory.get_recent_memories(limit: 100)
95
- filtered = memories.select { |m| m["tags"]&.include?(tag) }
96
- format_memories(filtered.first(limit))
73
+ def get_intents(limit: 10)
74
+ intents = @memory.get_intents(limit: limit)
75
+ format_memories(intents)
97
76
  rescue => e
98
77
  { "error" => e.message }
99
78
  end
100
79
 
101
80
  # Get memories by type (pitfall, lesson, success)
102
81
  def get_by_type(type, limit: 10)
103
- memories = @memory.get_recent_memories(limit: 100)
104
- filtered = memories.select { |m| m["type"] == type }
105
- format_memories(filtered.first(limit))
82
+ memories = @memory.get_memories_by_type(type: type, limit: limit)
83
+ format_memories(memories)
106
84
  rescue => e
107
85
  { "error" => e.message }
108
86
  end
@@ -118,14 +96,7 @@ module Agentf
118
96
 
119
97
  # Search memories by keyword in title or description
120
98
  def search(query, limit: 10)
121
- memories = @memory.get_recent_memories(limit: 100)
122
- q = query.downcase
123
- filtered = memories.select do |m|
124
- m["title"]&.downcase&.include?(q) ||
125
- m["description"]&.downcase&.include?(q) ||
126
- m["context"]&.downcase&.include?(q)
127
- end
128
- format_memories(filtered.first(limit))
99
+ format_memories(@memory.search_memories(query: query, limit: limit))
129
100
  rescue => e
130
101
  { "error" => e.message }
131
102
  end
@@ -133,19 +104,22 @@ module Agentf
133
104
  # Get summary statistics
134
105
  def get_summary
135
106
  memories = @memory.get_recent_memories(limit: 100)
136
- tags = @memory.get_all_tags
137
107
 
138
108
  {
139
109
  "total_memories" => memories.length,
140
110
  "by_type" => {
141
- "pitfall" => memories.count { |m| m["type"] == "pitfall" },
111
+ "episode" => memories.count { |m| m["type"] == "episode" },
142
112
  "lesson" => memories.count { |m| m["type"] == "lesson" },
143
- "success" => memories.count { |m| m["type"] == "success" },
113
+ "playbook" => memories.count { |m| m["type"] == "playbook" },
144
114
  "business_intent" => memories.count { |m| m["type"] == "business_intent" },
145
115
  "feature_intent" => memories.count { |m| m["type"] == "feature_intent" }
146
116
  },
117
+ "by_outcome" => {
118
+ "positive" => memories.count { |m| m["outcome"] == "positive" },
119
+ "negative" => memories.count { |m| m["outcome"] == "negative" },
120
+ "neutral" => memories.count { |m| m["outcome"] == "neutral" }
121
+ },
147
122
  "by_agent" => memories.each_with_object(Hash.new(0)) { |m, h| h[m["agent"]] += 1 },
148
- "unique_tags" => tags.length,
149
123
  "project" => @project
150
124
  }
151
125
  rescue => e
@@ -181,7 +155,7 @@ module Agentf
181
155
  "description" => m["description"],
182
156
  "context" => m["context"],
183
157
  "code_snippet" => m["code_snippet"],
184
- "tags" => m["tags"],
158
+ "outcome" => m["outcome"],
185
159
  "agent" => m["agent"],
186
160
  "metadata" => m["metadata"],
187
161
  "entity_ids" => m["entity_ids"],
@@ -7,8 +7,6 @@ module Agentf
7
7
  class Metrics
8
8
  NAME = "metrics"
9
9
 
10
- WORKFLOW_METRICS_TAG = "workflow_metric"
11
-
12
10
  def self.manifest
13
11
  {
14
12
  "name" => NAME,
@@ -28,20 +26,23 @@ module Agentf
28
26
 
29
27
  def record_workflow(workflow_state)
30
28
  metrics = extract_metrics(workflow_state)
31
-
32
- @memory.store_episode(
33
- type: "success",
34
- title: metric_title(metrics),
35
- description: metric_description(metrics),
36
- context: metric_context(metrics),
37
- tags: metric_tags(metrics),
38
- agent: Agentf::AgentRoles::ORCHESTRATOR,
39
- code_snippet: ""
40
- )
41
-
42
- { "status" => "recorded", "metrics" => metrics }
43
- rescue StandardError => e
44
- { "status" => "error", "error" => e.message }
29
+ begin
30
+ @memory.store_episode(
31
+ type: "episode",
32
+ title: metric_title(metrics),
33
+ description: metric_description(metrics),
34
+ context: metric_context(metrics),
35
+ agent: Agentf::AgentRoles::ORCHESTRATOR,
36
+ outcome: "positive",
37
+ metadata: { "workflow_metric" => true },
38
+ code_snippet: ""
39
+ )
40
+ { "status" => "recorded", "metrics" => metrics }
41
+ rescue Agentf::Memory::RedisMemory::ConfirmationRequired => e
42
+ { "status" => "confirmation_required", "confirmation_details" => e.details, "attempted" => { "action" => "record_workflow" } }
43
+ rescue StandardError => e
44
+ { "status" => "error", "error" => e.message }
45
+ end
45
46
  end
46
47
 
47
48
  def summary(limit: 100)
@@ -169,14 +170,6 @@ module Agentf
169
170
  }.to_json
170
171
  end
171
172
 
172
- def metric_tags(metrics)
173
- [
174
- WORKFLOW_METRICS_TAG,
175
- "provider:#{metrics['provider'].to_s.downcase}",
176
- "workflow:#{metrics['workflow_type']}"
177
- ]
178
- end
179
-
180
173
  def top_contract_violations(records)
181
174
  counts = Hash.new(0)
182
175
  records.each do |record|
@@ -189,7 +182,7 @@ module Agentf
189
182
  memories = @memory.get_recent_memories(limit: limit)
190
183
 
191
184
  memories
192
- .select { |m| Array(m["tags"]).include?(WORKFLOW_METRICS_TAG) }
185
+ .select { |m| m.dig("metadata", "workflow_metric") == true }
193
186
  .map do |m|
194
187
  context = parse_context_json(m["context"])
195
188
  context
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentf
4
+ module Commands
5
+ class Registry
6
+ def initialize(map = {})
7
+ @map = map
8
+ end
9
+
10
+ def register(name, impl)
11
+ @map[name.to_s] = impl
12
+ end
13
+
14
+ def fetch(name)
15
+ @map.fetch(name.to_s)
16
+ end
17
+
18
+ def call(command_name, action, *args)
19
+ impl = fetch(command_name)
20
+ if impl.respond_to?(action)
21
+ impl.public_send(action, *args)
22
+ else
23
+ raise "Command #{command_name} does not implement #{action}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Agentf
4
4
  class ContextBuilder
5
- def initialize(memory:)
5
+ def initialize(memory:, embedding_provider: Agentf::EmbeddingProvider.new)
6
6
  @memory = memory
7
+ @embedding_provider = embedding_provider
7
8
  end
8
9
 
9
10
  def build(agent:, workflow_state:, limit: 8)
@@ -13,23 +14,12 @@ module Agentf
13
14
  @memory.get_agent_context(
14
15
  agent: agent,
15
16
  task_type: task_type,
16
- query_embedding: simple_embedding(task),
17
+ query_text: task,
18
+ query_embedding: @embedding_provider.embed(task),
17
19
  limit: limit
18
20
  )
19
21
  rescue StandardError
20
22
  { "agent" => agent, "intent" => [], "memories" => [], "similar_tasks" => [] }
21
23
  end
22
-
23
- private
24
-
25
- def simple_embedding(text)
26
- normalized = text.to_s.downcase
27
- [
28
- normalized.include?("fix") || normalized.include?("bug") ? 1.0 : 0.0,
29
- normalized.include?("feature") || normalized.include?("add") ? 1.0 : 0.0,
30
- normalized.include?("security") ? 1.0 : 0.0,
31
- normalized.length.to_f / 100.0
32
- ]
33
- end
34
24
  end
35
25
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Agentf
6
+ class EmbeddingProvider
7
+ DIMENSIONS = 64
8
+
9
+ def initialize(dimensions: DIMENSIONS)
10
+ @dimensions = dimensions
11
+ end
12
+
13
+ def embed(text)
14
+ tokens = tokenize(text)
15
+ return [] if tokens.empty?
16
+
17
+ vector = Array.new(@dimensions, 0.0)
18
+ tokens.each do |token|
19
+ hash = Digest::SHA256.hexdigest(token)[0, 8].to_i(16)
20
+ vector[hash % @dimensions] += 1.0
21
+ end
22
+
23
+ magnitude = Math.sqrt(vector.sum { |value| value * value })
24
+ return vector if magnitude.zero?
25
+
26
+ vector.map { |value| (value / magnitude).round(8) }
27
+ end
28
+
29
+ private
30
+
31
+ def tokenize(text)
32
+ text.to_s.downcase.scan(/[a-z0-9_]+/).reject { |token| token.length < 2 }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agentf
6
+ module Evals
7
+ class Report
8
+ def initialize(output_root: Runner::DEFAULT_OUTPUT_ROOT)
9
+ @output_root = File.expand_path(output_root)
10
+ end
11
+
12
+ attr_reader :output_root
13
+
14
+ def generate(limit: nil, since: nil, scenario: nil)
15
+ records = load_history
16
+ records = filter_since(records, since)
17
+ records = filter_scenario(records, scenario)
18
+ records = records.last(limit) if limit && limit.positive?
19
+
20
+ {
21
+ "output_root" => output_root,
22
+ "history_path" => history_path,
23
+ "count" => records.length,
24
+ "passes" => records.count { |record| record["status"] == "passed" },
25
+ "failures" => records.count { |record| record["status"] == "failed" },
26
+ "retry_summary" => summarize_retries(records),
27
+ "memory_effectiveness" => summarize_memory_effectiveness(records),
28
+ "providers" => summarize_dimension(records, "providers"),
29
+ "models" => summarize_dimension(records, "models"),
30
+ "scenarios" => summarize_scenarios(records)
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def history_path
37
+ File.join(output_root, "history.jsonl")
38
+ end
39
+
40
+ def load_history
41
+ return [] unless File.exist?(history_path)
42
+
43
+ File.readlines(history_path, chomp: true).filter_map do |line|
44
+ next if line.to_s.strip.empty?
45
+
46
+ JSON.parse(line)
47
+ rescue JSON::ParserError
48
+ nil
49
+ end
50
+ end
51
+
52
+ def filter_since(records, since)
53
+ return records unless since
54
+
55
+ cutoff = since.is_a?(Time) ? since : Time.parse(since.to_s)
56
+ records.select do |record|
57
+ recorded_at = record["recorded_at"]
58
+ recorded_at && Time.parse(recorded_at) >= cutoff
59
+ rescue ArgumentError
60
+ false
61
+ end
62
+ end
63
+
64
+ def filter_scenario(records, scenario)
65
+ return records if scenario.to_s.strip.empty?
66
+
67
+ records.select { |record| record["scenario"] == scenario }
68
+ end
69
+
70
+ def summarize_retries(records)
71
+ retried = records.count { |record| record["retry_count"].to_i.positive? }
72
+ {
73
+ "retried_runs" => retried,
74
+ "total_retries" => records.sum { |record| record["retry_count"].to_i },
75
+ "flaky_runs" => records.count { |record| record["flaky"] == true }
76
+ }
77
+ end
78
+
79
+ def summarize_dimension(records, key)
80
+ summary = Hash.new { |hash, name| hash[name] = base_stats }
81
+
82
+ records.each do |record|
83
+ Array(record[key]).each do |name|
84
+ entry = summary[name]
85
+ update_stats(entry, record)
86
+ end
87
+ end
88
+
89
+ summary
90
+ end
91
+
92
+ def summarize_scenarios(records)
93
+ summary = Hash.new { |hash, name| hash[name] = base_stats.merge("last_status" => nil, "last_recorded_at" => nil) }
94
+
95
+ records.each do |record|
96
+ entry = summary[record["scenario"]]
97
+ update_stats(entry, record)
98
+ update_memory_effectiveness(entry, record)
99
+ entry["last_status"] = record["status"]
100
+ entry["last_recorded_at"] = record["recorded_at"]
101
+ end
102
+
103
+ summary
104
+ end
105
+
106
+ def summarize_memory_effectiveness(records)
107
+ relevant = records.filter_map { |record| record["memory_effectiveness"] }
108
+ {
109
+ "tracked_runs" => relevant.length,
110
+ "retrieved_expected_memory" => relevant.count { |item| item["retrieved_expected_memory"] == true }
111
+ }
112
+ end
113
+
114
+ def base_stats
115
+ { "total" => 0, "passed" => 0, "failed" => 0, "retried" => 0, "flaky" => 0 }
116
+ end
117
+
118
+ def update_stats(entry, record)
119
+ entry["total"] += 1
120
+ entry[record["status"] == "passed" ? "passed" : "failed"] += 1
121
+ entry["retried"] += 1 if record["retry_count"].to_i.positive?
122
+ entry["flaky"] += 1 if record["flaky"] == true
123
+ end
124
+
125
+ def update_memory_effectiveness(entry, record)
126
+ effect = record["memory_effectiveness"]
127
+ return unless effect
128
+
129
+ entry["memory_tracked"] = entry.fetch("memory_tracked", 0) + 1
130
+ entry["memory_retrieved"] = entry.fetch("memory_retrieved", 0) + (effect["retrieved_expected_memory"] == true ? 1 : 0)
131
+ end
132
+ end
133
+ end
134
+ end