brainiac 0.0.14 → 0.0.16
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/Gemfile.lock +2 -2
- data/bin/brainiac +71 -0
- data/lib/brainiac/agents.rb +18 -0
- data/lib/brainiac/helpers.rb +4 -4
- data/lib/brainiac/hooks.rb +2 -0
- data/lib/brainiac/intent.rb +203 -6
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 123ee48772d662b7c3326b4c90584f3aa85cfeecf0c481f564cb3f79efb4355a
|
|
4
|
+
data.tar.gz: d81be4471f1665ca426d9c02cc278f04e0fc67a272b6f9c5a82ce7ba05ef00ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76b8df7eefa48bb122f7d75ba0730f0b8ac328ad284f45e137297dd650b7f6781bcc6ad14bd1a0a4f1a31a9c24392bd183a4b5b236f086a19bc504257f2cf180
|
|
7
|
+
data.tar.gz: 7f089331037cc192f3cb284471b497e688d0831a38088f968cf4c5eabd175539a8b8f8e1986ad38739ee7a1efdb1beb81d3777d9f63e99c131fd74d47629dcd8
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0.
|
|
4
|
+
brainiac (0.0.16)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -84,7 +84,7 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0.
|
|
87
|
+
brainiac (0.0.16)
|
|
88
88
|
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
89
89
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
data/bin/brainiac
CHANGED
|
@@ -120,6 +120,65 @@ rescue StandardError
|
|
|
120
120
|
[]
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# Notify the running server to reload configs (triggers agent lifecycle hooks).
|
|
124
|
+
# Silently does nothing if the server isn't running.
|
|
125
|
+
def notify_server_reload
|
|
126
|
+
config = load_config
|
|
127
|
+
server_url = config["server_url"] || "http://localhost:4567"
|
|
128
|
+
uri = URI("#{server_url}/api/reload")
|
|
129
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
130
|
+
http.open_timeout = 2
|
|
131
|
+
http.read_timeout = 5
|
|
132
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
133
|
+
http.request(request)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Run CLI-time plugin hooks for agent lifecycle events.
|
|
139
|
+
# Loads each installed plugin's CLI module and calls the hook method if defined.
|
|
140
|
+
# This runs synchronously and can prompt for interactive input.
|
|
141
|
+
def notify_plugins_agent_created(agent_key, entry)
|
|
142
|
+
plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
|
|
143
|
+
return unless File.exist?(plugins_file)
|
|
144
|
+
|
|
145
|
+
plugins_config = JSON.parse(File.read(plugins_file))
|
|
146
|
+
(plugins_config["plugins"] || []).each do |pentry|
|
|
147
|
+
pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
|
|
148
|
+
try_require_metadata(pname, pentry)
|
|
149
|
+
try_require_plugin_cli(pname, pentry)
|
|
150
|
+
mod = resolve_plugin_module_for(pname)
|
|
151
|
+
next unless mod.respond_to?(:on_agent_created)
|
|
152
|
+
|
|
153
|
+
mod.on_agent_created(agent_key, entry)
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Run CLI-time plugin hooks for agent removal.
|
|
162
|
+
def notify_plugins_agent_removed(agent_key, display_name)
|
|
163
|
+
plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
|
|
164
|
+
return unless File.exist?(plugins_file)
|
|
165
|
+
|
|
166
|
+
plugins_config = JSON.parse(File.read(plugins_file))
|
|
167
|
+
(plugins_config["plugins"] || []).each do |pentry|
|
|
168
|
+
pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
|
|
169
|
+
try_require_metadata(pname, pentry)
|
|
170
|
+
try_require_plugin_cli(pname, pentry)
|
|
171
|
+
mod = resolve_plugin_module_for(pname)
|
|
172
|
+
next unless mod.respond_to?(:on_agent_removed)
|
|
173
|
+
|
|
174
|
+
mod.on_agent_removed(agent_key, display_name)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
123
182
|
def wait_for_agents_to_finish
|
|
124
183
|
sessions = fetch_active_sessions
|
|
125
184
|
return true if sessions.empty?
|
|
@@ -1873,6 +1932,12 @@ when "agent"
|
|
|
1873
1932
|
brain_init(name)
|
|
1874
1933
|
end
|
|
1875
1934
|
|
|
1935
|
+
# Let plugins prompt for additional config (e.g. Discord user ID)
|
|
1936
|
+
notify_plugins_agent_created(agent_key, entry)
|
|
1937
|
+
|
|
1938
|
+
# Notify running server to reload and trigger :agent_added hook
|
|
1939
|
+
notify_server_reload
|
|
1940
|
+
|
|
1876
1941
|
when "remove", "delete", "rm"
|
|
1877
1942
|
name = ARGV.shift
|
|
1878
1943
|
unless name
|
|
@@ -1894,6 +1959,12 @@ when "agent"
|
|
|
1894
1959
|
puts "✓ Removed agent '#{display}' (#{agent_key}) from registry"
|
|
1895
1960
|
puts " Note: Persona files at ~/.brainiac/brain/persona/#{agent_key}/ were not deleted."
|
|
1896
1961
|
|
|
1962
|
+
# Let plugins clean up their config
|
|
1963
|
+
notify_plugins_agent_removed(agent_key, display)
|
|
1964
|
+
|
|
1965
|
+
# Notify running server to reload and trigger :agent_removed hook
|
|
1966
|
+
notify_server_reload
|
|
1967
|
+
|
|
1897
1968
|
when nil
|
|
1898
1969
|
puts <<~HELP
|
|
1899
1970
|
Usage: brainiac agent <name> <command> [args]
|
data/lib/brainiac/agents.rb
CHANGED
|
@@ -50,8 +50,26 @@ AGENT_REGISTRY = load_agent_registry
|
|
|
50
50
|
def reload_agent_registry!(force: false)
|
|
51
51
|
return unless file_changed?(AGENT_REGISTRY_FILE, force: force)
|
|
52
52
|
|
|
53
|
+
old_keys = AGENT_REGISTRY.keys.to_set
|
|
53
54
|
AGENT_REGISTRY.replace(load_agent_registry)
|
|
55
|
+
new_keys = AGENT_REGISTRY.keys.to_set
|
|
54
56
|
LOG.info "Reloaded agent registry: #{AGENT_REGISTRY.keys.join(", ")}"
|
|
57
|
+
|
|
58
|
+
# Emit lifecycle hooks for added/removed agents
|
|
59
|
+
added = new_keys - old_keys
|
|
60
|
+
removed = old_keys - new_keys
|
|
61
|
+
|
|
62
|
+
added.each do |key|
|
|
63
|
+
entry = AGENT_REGISTRY[key]
|
|
64
|
+
display_name = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
65
|
+
LOG.info "Agent added: #{display_name} (#{key})"
|
|
66
|
+
Brainiac.emit(:agent_added, agent_key: key, display_name: display_name, entry: entry)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
removed.each do |key|
|
|
70
|
+
LOG.info "Agent removed: #{key}"
|
|
71
|
+
Brainiac.emit(:agent_removed, agent_key: key)
|
|
72
|
+
end
|
|
55
73
|
end
|
|
56
74
|
|
|
57
75
|
# Get the env hash for an agent. Returns {} if none configured.
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -402,11 +402,11 @@ end
|
|
|
402
402
|
# Check if intent detection says to skip dispatching the agent.
|
|
403
403
|
# Returns true if the message should be skipped, false otherwise.
|
|
404
404
|
# Only runs if a raw message is provided, an agent is named, and intent is enabled.
|
|
405
|
-
def intent_skip?(message, agent_name:, source: nil, channel: nil)
|
|
405
|
+
def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
|
|
406
406
|
return false unless message && agent_name && intent_config["enabled"]
|
|
407
407
|
|
|
408
408
|
intent_channel = channel || source&.to_s || "conversation"
|
|
409
|
-
unless check_intent(message, agent_name: agent_name, channel: intent_channel)
|
|
409
|
+
unless check_intent(message, agent_name: agent_name, channel: intent_channel, context: context)
|
|
410
410
|
LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
|
|
411
411
|
return true
|
|
412
412
|
end
|
|
@@ -416,9 +416,9 @@ end
|
|
|
416
416
|
|
|
417
417
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
418
418
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
|
|
419
|
-
message: nil, channel: nil)
|
|
419
|
+
message: nil, channel: nil, context: nil)
|
|
420
420
|
# Intent gate: if a raw message is provided, check whether the agent should respond.
|
|
421
|
-
return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel)
|
|
421
|
+
return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
|
|
422
422
|
|
|
423
423
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
424
424
|
chdir ||= resolved["repo_path"]
|
data/lib/brainiac/hooks.rb
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
#
|
|
10
10
|
# Events:
|
|
11
11
|
# :agent_completed — After an agent session finishes (success or failure)
|
|
12
|
+
# :agent_added — When a new agent appears in the registry (after reload)
|
|
13
|
+
# :agent_removed — When an agent is removed from the registry (after reload)
|
|
12
14
|
# :pr_merged — After a GitHub PR is merged
|
|
13
15
|
# :pr_opened — After a GitHub PR is opened
|
|
14
16
|
# :pr_reviewed — After a PR review is submitted
|
data/lib/brainiac/intent.rb
CHANGED
|
@@ -38,15 +38,27 @@ INTENT_CONFIG_DEFAULTS = {
|
|
|
38
38
|
INTENT_PROMPT_TEMPLATE = <<~PROMPT
|
|
39
39
|
You are a message router for a {{CHANNEL}}. An AI agent named {{AGENT_NAME}} is participating in this conversation. Your job: determine if the latest message requires {{AGENT_NAME}} to take action or respond.
|
|
40
40
|
|
|
41
|
+
{{AGENT_ROSTER}}
|
|
42
|
+
|
|
41
43
|
Rules:
|
|
42
|
-
- If the message
|
|
44
|
+
- If the message addresses {{AGENT_NAME}} by name, gives {{AGENT_NAME}} instructions, or asks {{AGENT_NAME}} a question → yes
|
|
45
|
+
- If the message continues a conversation directed at {{AGENT_NAME}} (e.g. {{AGENT_NAME}} was the last to respond and the human follows up) → yes
|
|
46
|
+
- If the message starts with or addresses a DIFFERENT person/agent (not {{AGENT_NAME}}) → no
|
|
47
|
+
- If the message contains another agent's name (e.g. "Effie, ...", "Another one Effie", "Hey Galen") and does NOT contain "{{AGENT_NAME}}" → no
|
|
43
48
|
- If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
|
|
44
|
-
- If the message is a simple acknowledgment (
|
|
45
|
-
- If the message is asking a question to another person or agent → no
|
|
49
|
+
- If the message is a simple acknowledgment ("thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
|
|
50
|
+
- If the message is asking a question to another person or agent (not {{AGENT_NAME}}) → no
|
|
51
|
+
- If the message is responding to or commenting on what someone OTHER than {{AGENT_NAME}} just said → no
|
|
52
|
+
- If the conversation context shows a DIFFERENT agent was the last to respond, and the human's follow-up does not mention {{AGENT_NAME}} by name → no
|
|
46
53
|
- If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
|
|
47
54
|
|
|
55
|
+
Critical: "addresses someone else" means someone whose name is NOT {{AGENT_NAME}}. If the message says "{{AGENT_NAME}}, ..." that IS addressed to {{AGENT_NAME}} → yes.
|
|
56
|
+
|
|
48
57
|
Respond with ONLY "yes" or "no" — nothing else.
|
|
49
58
|
|
|
59
|
+
Context:
|
|
60
|
+
{{CONTEXT}}
|
|
61
|
+
|
|
50
62
|
Latest message:
|
|
51
63
|
{{MESSAGE}}
|
|
52
64
|
PROMPT
|
|
@@ -67,33 +79,114 @@ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
|
|
|
67
79
|
{{MESSAGE}}
|
|
68
80
|
PROMPT
|
|
69
81
|
|
|
82
|
+
# Custom error for missing Ollama models — provides actionable install instructions.
|
|
83
|
+
class OllamaModelNotFoundError < RuntimeError
|
|
84
|
+
attr_reader :model_name, :endpoint
|
|
85
|
+
|
|
86
|
+
def initialize(model_name, endpoint)
|
|
87
|
+
@model_name = model_name
|
|
88
|
+
@endpoint = endpoint
|
|
89
|
+
super(<<~MSG.strip)
|
|
90
|
+
Ollama model '#{model_name}' is not installed.
|
|
91
|
+
|
|
92
|
+
To install it, run:
|
|
93
|
+
ollama pull #{model_name}
|
|
94
|
+
|
|
95
|
+
Or change the model in ~/.brainiac/brainiac.json → intent.model
|
|
96
|
+
|
|
97
|
+
Available models can be listed with:
|
|
98
|
+
ollama list
|
|
99
|
+
MSG
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
70
103
|
# Load intent config from brainiac.json, merging with defaults.
|
|
71
104
|
def intent_config
|
|
72
105
|
raw = BRAINIAC_CONFIG["intent"] || {}
|
|
73
106
|
INTENT_CONFIG_DEFAULTS.merge(raw)
|
|
74
107
|
end
|
|
75
108
|
|
|
109
|
+
# Validate that the configured intent model is available in Ollama.
|
|
110
|
+
# Called at server startup — aborts with a helpful error if the model is missing.
|
|
111
|
+
#
|
|
112
|
+
# @param config [Hash] Intent configuration
|
|
113
|
+
# @raise [OllamaModelNotFoundError] if the model isn't installed
|
|
114
|
+
def validate_intent_model!(config)
|
|
115
|
+
base_uri = URI(config["endpoint"])
|
|
116
|
+
show_uri = URI("#{base_uri.scheme}://#{base_uri.host}:#{base_uri.port}/api/show")
|
|
117
|
+
|
|
118
|
+
http = Net::HTTP.new(show_uri.host, show_uri.port)
|
|
119
|
+
http.open_timeout = 5
|
|
120
|
+
http.read_timeout = 5
|
|
121
|
+
|
|
122
|
+
request = Net::HTTP::Post.new(show_uri.path, "Content-Type" => "application/json")
|
|
123
|
+
request.body = JSON.generate({ model: config["model"] })
|
|
124
|
+
|
|
125
|
+
response = http.request(request)
|
|
126
|
+
|
|
127
|
+
if response.code == "404" || (!response.is_a?(Net::HTTPSuccess) && response.body&.include?("not found"))
|
|
128
|
+
raise OllamaModelNotFoundError.new(config["model"], config["endpoint"])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
true
|
|
132
|
+
rescue Errno::ECONNREFUSED
|
|
133
|
+
raise "Ollama is not running at #{config["endpoint"]}. Start it with: ollama serve"
|
|
134
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
135
|
+
LOG.warn "[Intent] Could not validate model (Ollama timed out) — will check at first use"
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
76
139
|
# Check whether a message requires an agent to respond.
|
|
77
140
|
#
|
|
78
141
|
# @param message [String] The message text to classify
|
|
79
142
|
# @param agent_name [String] The agent being addressed
|
|
80
143
|
# @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
|
|
144
|
+
# @param context [String, nil] Recent conversation history for flow detection
|
|
81
145
|
# @return [Boolean] true if the agent should respond, false if it can be skipped
|
|
82
|
-
def check_intent(message, agent_name:, channel: "conversation")
|
|
146
|
+
def check_intent(message, agent_name:, channel: "conversation", context: nil)
|
|
83
147
|
config = intent_config
|
|
84
148
|
return true unless config["enabled"]
|
|
85
149
|
return true if message.nil? || message.strip.empty?
|
|
86
150
|
|
|
151
|
+
# Deterministic pre-check: if the message explicitly names another agent
|
|
152
|
+
# and does NOT name this agent, skip without consulting the LLM.
|
|
153
|
+
# This handles the common case of "Effie, tell me another" when checking Galen.
|
|
154
|
+
if intent_names_other_agent?(message, agent_name)
|
|
155
|
+
LOG.info "[Intent] Deterministic skip for #{agent_name} — message addresses another agent"
|
|
156
|
+
return false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
context_block = if context && !context.strip.empty?
|
|
160
|
+
# Keep only last 5 messages for the small local model — enough to determine
|
|
161
|
+
# conversational flow without overwhelming the context window.
|
|
162
|
+
recent = context.strip.lines.last(5).join
|
|
163
|
+
"Recent conversation (most recent last):\n#{recent}\n\n"
|
|
164
|
+
else
|
|
165
|
+
""
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Build agent roster for the prompt so the model knows which names are agents
|
|
169
|
+
roster_block = build_intent_agent_roster(agent_name)
|
|
170
|
+
|
|
87
171
|
prompt = INTENT_PROMPT_TEMPLATE
|
|
88
172
|
.gsub("{{AGENT_NAME}}", agent_name)
|
|
89
173
|
.gsub("{{CHANNEL}}", channel)
|
|
174
|
+
.gsub("{{AGENT_ROSTER}}", roster_block)
|
|
175
|
+
.gsub("{{CONTEXT}}", context_block)
|
|
90
176
|
.gsub("{{MESSAGE}}", message.strip)
|
|
91
177
|
|
|
92
178
|
LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
|
|
179
|
+
LOG.debug "[Intent] Full prompt:\n#{prompt}" if LOG.debug?
|
|
93
180
|
response = query_local_llm(prompt, config)
|
|
94
181
|
result = positive_intent?(response)
|
|
95
182
|
LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
|
|
96
183
|
result
|
|
184
|
+
rescue OllamaModelNotFoundError => e
|
|
185
|
+
LOG.error "[Intent] #{e.message}"
|
|
186
|
+
LOG.error "[Intent] Disabling intent classification until model is installed."
|
|
187
|
+
BRAINIAC_CONFIG["intent"] ||= {}
|
|
188
|
+
BRAINIAC_CONFIG["intent"]["enabled"] = false
|
|
189
|
+
true
|
|
97
190
|
rescue StandardError => e
|
|
98
191
|
LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
|
|
99
192
|
true
|
|
@@ -121,12 +214,17 @@ def query_local_llm(prompt, config)
|
|
|
121
214
|
request.body = JSON.generate(payload)
|
|
122
215
|
|
|
123
216
|
response = http.request(request)
|
|
124
|
-
|
|
217
|
+
|
|
218
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
219
|
+
raise OllamaModelNotFoundError.new(config["model"], config["endpoint"]) if response.code == "404" && response.body&.include?("not found")
|
|
220
|
+
|
|
221
|
+
raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}"
|
|
222
|
+
end
|
|
125
223
|
|
|
126
224
|
body = JSON.parse(response.body)
|
|
127
225
|
body["response"] || ""
|
|
128
226
|
rescue Errno::ECONNREFUSED
|
|
129
|
-
raise "Ollama not running at #{config["endpoint"]}"
|
|
227
|
+
raise "Ollama not running at #{config["endpoint"]}. Start it with: ollama serve"
|
|
130
228
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
131
229
|
raise "Ollama timed out after #{config["timeout"]}s"
|
|
132
230
|
end
|
|
@@ -177,3 +275,102 @@ def pending_work_detected?(response)
|
|
|
177
275
|
cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
|
|
178
276
|
cleaned == "yes"
|
|
179
277
|
end
|
|
278
|
+
|
|
279
|
+
# Deterministic check: does the message explicitly name another known agent
|
|
280
|
+
# without mentioning this agent? If so, the message is clearly directed elsewhere.
|
|
281
|
+
#
|
|
282
|
+
# Only triggers when:
|
|
283
|
+
# 1. The message contains another agent's display name (case-insensitive word boundary match)
|
|
284
|
+
# 2. The message does NOT contain this agent's name
|
|
285
|
+
#
|
|
286
|
+
# This avoids the LLM entirely for obvious cases like "Effie, tell me another"
|
|
287
|
+
# when evaluating whether Galen should respond.
|
|
288
|
+
#
|
|
289
|
+
# Determines if the message is DIRECTLY ADDRESSED to another agent (not just mentioning them).
|
|
290
|
+
# Only triggers on vocative patterns — where the name is used to call someone, not refer to them.
|
|
291
|
+
#
|
|
292
|
+
# Patterns that indicate direct address (skip this agent):
|
|
293
|
+
# - "Effie, tell me a joke" (name + comma at start)
|
|
294
|
+
# - "hey Effie one more" (greeting + name)
|
|
295
|
+
# - "Another one Effie" (name at end, imperative)
|
|
296
|
+
# - "Effie tell me more" (name at start, imperative — no comma but first word)
|
|
297
|
+
#
|
|
298
|
+
# Patterns that are merely MENTIONING (do NOT skip):
|
|
299
|
+
# - "What do you think about Effie's answer?" (talking about Effie)
|
|
300
|
+
# - "Was Effie nice to you?" (asking about Effie)
|
|
301
|
+
# - "I liked what Effie said" (referencing Effie)
|
|
302
|
+
#
|
|
303
|
+
# @param message [String] The message text
|
|
304
|
+
# @param agent_name [String] The agent being evaluated
|
|
305
|
+
# @return [Boolean] true if another agent is directly addressed and this one isn't
|
|
306
|
+
def intent_names_other_agent?(message, agent_name)
|
|
307
|
+
return false unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
|
|
308
|
+
|
|
309
|
+
msg_lower = message.downcase.strip
|
|
310
|
+
agent_lower = agent_name.downcase
|
|
311
|
+
|
|
312
|
+
# If this agent IS named in the message, never deterministic skip
|
|
313
|
+
return false if msg_lower.match?(/\b#{Regexp.escape(agent_lower)}\b/)
|
|
314
|
+
|
|
315
|
+
# Check if any OTHER agent is directly addressed (not merely mentioned)
|
|
316
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
317
|
+
display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
318
|
+
other_lower = display.downcase
|
|
319
|
+
next if other_lower == agent_lower
|
|
320
|
+
next unless msg_lower.match?(/\b#{Regexp.escape(other_lower)}\b/)
|
|
321
|
+
|
|
322
|
+
# Agent name is present — now determine if it's direct address or mere mention
|
|
323
|
+
return true if directly_addressed_to?(msg_lower, other_lower)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
false
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Heuristics for detecting direct address (vocative use of a name).
|
|
330
|
+
# Returns true when the name is used to CALL someone, not talk ABOUT them.
|
|
331
|
+
#
|
|
332
|
+
# @param msg [String] Lowercased, stripped message
|
|
333
|
+
# @param name [String] Lowercased agent name to check
|
|
334
|
+
# @return [Boolean]
|
|
335
|
+
def directly_addressed_to?(msg, name)
|
|
336
|
+
escaped = Regexp.escape(name)
|
|
337
|
+
|
|
338
|
+
# Pattern 1: Name at the very start (with or without comma/colon)
|
|
339
|
+
# "Effie, tell me a joke" / "Effie tell me more" / "Effie: do the thing"
|
|
340
|
+
return true if msg.match?(/\A#{escaped}[\s,:!]/i)
|
|
341
|
+
|
|
342
|
+
# Pattern 2: Name at the very end (imperative directed at them)
|
|
343
|
+
# "Another one Effie" / "one more Effie"
|
|
344
|
+
# BUT NOT when preceded by a preposition — "agree with Effie?" is about Effie, not to her
|
|
345
|
+
if msg.match?(/\s#{escaped}\s*[.!?]?\z/i)
|
|
346
|
+
prepositions = /\b(?:with|about|from|to|for|of|by|like|than|as|at|on|against|toward|towards)\s+#{escaped}\s*[.!?]?\z/i
|
|
347
|
+
return true unless msg.match?(prepositions)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Pattern 3: Greeting/vocative prefix + name
|
|
351
|
+
# "hey Effie" / "yo Effie" / "ok Effie" / "thanks Effie"
|
|
352
|
+
return true if msg.match?(/\b(?:hey|hi|yo|ok|okay|thanks|thank you|please)\s+#{escaped}\b/i)
|
|
353
|
+
|
|
354
|
+
# Pattern 4: Name followed by comma mid-sentence (vocative comma)
|
|
355
|
+
# "so Effie, what do you think?"
|
|
356
|
+
return true if msg.match?(/\b#{escaped}\s*,/i)
|
|
357
|
+
|
|
358
|
+
false
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Build a compact agent roster string for the intent prompt.
|
|
362
|
+
# Helps the LLM identify which names in the conversation are agents.
|
|
363
|
+
#
|
|
364
|
+
# @param agent_name [String] The current agent (highlighted)
|
|
365
|
+
# @return [String] Formatted roster for prompt injection
|
|
366
|
+
def build_intent_agent_roster(agent_name)
|
|
367
|
+
return "" unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
|
|
368
|
+
|
|
369
|
+
names = AGENT_REGISTRY.map do |key, entry|
|
|
370
|
+
entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
371
|
+
end.uniq
|
|
372
|
+
|
|
373
|
+
return "" if names.size <= 1
|
|
374
|
+
|
|
375
|
+
"Known agents in this system: #{names.join(", ")}. You are routing for #{agent_name}."
|
|
376
|
+
end
|
data/lib/brainiac/version.rb
CHANGED
data/receiver.rb
CHANGED
|
@@ -101,6 +101,15 @@ end
|
|
|
101
101
|
LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
|
|
102
102
|
if intent_config["enabled"]
|
|
103
103
|
LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
|
|
104
|
+
begin
|
|
105
|
+
validate_intent_model!(intent_config)
|
|
106
|
+
LOG.info "[Intent] Model '#{intent_config["model"]}' verified ✓"
|
|
107
|
+
rescue OllamaModelNotFoundError => e
|
|
108
|
+
LOG.fatal "[Intent] FATAL: #{e.message}"
|
|
109
|
+
abort "\n#{e.message}\n"
|
|
110
|
+
rescue RuntimeError => e
|
|
111
|
+
LOG.warn "[Intent] Startup validation skipped: #{e.message}"
|
|
112
|
+
end
|
|
104
113
|
else
|
|
105
114
|
LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
|
|
106
115
|
end
|