anima-core 1.1.1 → 1.1.3
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/.reek.yml +10 -0
- data/app/channels/session_channel.rb +2 -6
- data/app/decorators/tool_decorator.rb +59 -4
- data/app/models/event.rb +21 -2
- data/app/models/session.rb +37 -5
- data/lib/agent_loop.rb +11 -3
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +4 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/subscribers/subagent_message_router.rb +12 -12
- data/lib/llm/client.rb +23 -9
- data/lib/mneme/search.rb +24 -7
- data/lib/shell_session.rb +2 -1
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/bash.rb +90 -7
- data/lib/workflows/registry.rb +1 -1
- data/templates/config.toml +6 -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: 583896eaa09036e71d6e6b6ee013a021965acc193340ba735e4c0a0a6be6e6dc
|
|
4
|
+
data.tar.gz: f1f173f5129c37785a01119daa3cce27a0b935e0fdac256ffb0b412aad87483c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 312effa8e79b480bd33a78093f192d6a6997fc0430aac6fe09528da0c95461feade771b3ecbcd13c0b7ad036d8a1c4cd2e6a443da61332baf3a8b34c57626ca5
|
|
7
|
+
data.tar.gz: 7c3578adb7158f0c32caa7f26ec3ea4d8189502e0bab27b0070f7a98a6423c6d311ddcd429edb7d451dadecd4282b5c7e874b6ce302fccdd1337103034eee1d2
|
data/.reek.yml
CHANGED
|
@@ -39,6 +39,10 @@ detectors:
|
|
|
39
39
|
- "Tools::SubagentPrompts#assign_nickname_via_brain"
|
|
40
40
|
# Validation methods naturally reference the validated value more than self.
|
|
41
41
|
- "AnalyticalBrain::Tools::AssignNickname#validate"
|
|
42
|
+
# Tool execute methods naturally reference input hash and shell result hash.
|
|
43
|
+
- "Tools::Bash#execute"
|
|
44
|
+
- "Tools::Bash#execute_single"
|
|
45
|
+
- "Tools::Bash#execute_batch"
|
|
42
46
|
# Delivery method orchestrates session, event, and agent_loop — inherent.
|
|
43
47
|
- "AgentRequestJob#deliver_persisted_event"
|
|
44
48
|
# Private helpers don't need instance state to be valid.
|
|
@@ -77,9 +81,15 @@ detectors:
|
|
|
77
81
|
# Runner checks session type to compose responsibilities — the core dispatch.
|
|
78
82
|
- "AnalyticalBrain::Runner"
|
|
79
83
|
# EventDecorator holds shared rendering constants (icons, markers, dispatch maps).
|
|
84
|
+
# Event model holds domain type constants (TYPES, CONTEXT_TYPES, SPAWN_TOOLS, etc.).
|
|
80
85
|
TooManyConstants:
|
|
81
86
|
exclude:
|
|
82
87
|
- "EventDecorator"
|
|
88
|
+
- "Event"
|
|
89
|
+
# encode_utf8 is descriptive — the digit triggers a false positive.
|
|
90
|
+
UncommunicativeMethodName:
|
|
91
|
+
exclude:
|
|
92
|
+
- "ToolDecorator#self.encode_utf8"
|
|
83
93
|
# Abstract base class methods declare parameters for the subclass contract.
|
|
84
94
|
UnusedParameters:
|
|
85
95
|
exclude:
|
|
@@ -51,6 +51,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
51
51
|
# until the current agent loop completes.
|
|
52
52
|
#
|
|
53
53
|
# @param data [Hash] must include "content" with the user's message text
|
|
54
|
+
# @see Session#enqueue_user_message
|
|
54
55
|
def speak(data)
|
|
55
56
|
content = data["content"].to_s.strip
|
|
56
57
|
return if content.empty?
|
|
@@ -58,12 +59,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
58
59
|
session = Session.find_by(id: @current_session_id)
|
|
59
60
|
return unless session
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
|
|
63
|
-
else
|
|
64
|
-
event = session.create_user_event(content)
|
|
65
|
-
AgentRequestJob.perform_later(session.id, event_id: event.id)
|
|
66
|
-
end
|
|
62
|
+
session.enqueue_user_message(content, bounce_back: true)
|
|
67
63
|
end
|
|
68
64
|
|
|
69
65
|
# Recalls the most recent pending message for editing. Deletes the
|
|
@@ -21,19 +21,74 @@ class ToolDecorator
|
|
|
21
21
|
"web_get" => "WebGetToolDecorator"
|
|
22
22
|
}.freeze
|
|
23
23
|
|
|
24
|
-
# Factory: dispatches to the tool-specific decorator
|
|
24
|
+
# Factory: dispatches to the tool-specific decorator, then sanitizes
|
|
25
|
+
# the result for safe LLM consumption.
|
|
26
|
+
#
|
|
27
|
+
# Sanitization guarantees the final string is UTF-8 encoded, free of
|
|
28
|
+
# ANSI escape codes, and stripped of control characters that carry no
|
|
29
|
+
# meaning for an LLM. This is the single gate — no tool or decorator
|
|
30
|
+
# subclass needs to think about encoding or terminal noise.
|
|
25
31
|
#
|
|
26
32
|
# @param tool_name [String] registered tool name
|
|
27
33
|
# @param result [String, Hash] raw tool execution result
|
|
28
|
-
# @return [String, Hash]
|
|
34
|
+
# @return [String, Hash] sanitized result (String) or original error Hash
|
|
29
35
|
def self.call(tool_name, result)
|
|
30
36
|
return result if result.is_a?(Hash) && result.key?(:error)
|
|
31
37
|
|
|
32
38
|
klass_name = DECORATOR_MAP[tool_name]
|
|
33
|
-
|
|
39
|
+
result = klass_name.constantize.new.call(result) if klass_name
|
|
40
|
+
|
|
41
|
+
sanitize_for_llm(result)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Ensures a tool result string is safe for LLM consumption by
|
|
45
|
+
# composing {encode_utf8}, {strip_ansi}, and {strip_control_chars}.
|
|
46
|
+
#
|
|
47
|
+
# Non-string results pass through unchanged.
|
|
48
|
+
#
|
|
49
|
+
# @param result [String, Object] tool output to sanitize
|
|
50
|
+
# @return [String, Object] sanitized string or original object
|
|
51
|
+
def self.sanitize_for_llm(result)
|
|
52
|
+
return result unless result.is_a?(String)
|
|
53
|
+
|
|
54
|
+
strip_control_chars(strip_ansi(encode_utf8(result)))
|
|
55
|
+
end
|
|
56
|
+
private_class_method :sanitize_for_llm
|
|
34
57
|
|
|
35
|
-
|
|
58
|
+
# Force-encodes a string to UTF-8, replacing invalid or undefined
|
|
59
|
+
# bytes with the Unicode replacement character (U+FFFD).
|
|
60
|
+
#
|
|
61
|
+
# @param str [String] input in any encoding (commonly ASCII-8BIT from PTY)
|
|
62
|
+
# @return [String] valid UTF-8 string
|
|
63
|
+
def self.encode_utf8(str)
|
|
64
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
65
|
+
end
|
|
66
|
+
private_class_method :encode_utf8
|
|
67
|
+
|
|
68
|
+
# CSI (colors, cursor, DEC private modes), OSC (terminal title),
|
|
69
|
+
# charset designation, single-char commands
|
|
70
|
+
ANSI_ESCAPE = /\e\[[?>=<0-9;]*[A-Za-z]|\e\][^\a\e]*(?:\a|\e\\)|\e[()][0-9A-Za-z]|\e[>=<78NOMDEHcn]/
|
|
71
|
+
private_constant :ANSI_ESCAPE
|
|
72
|
+
|
|
73
|
+
# Strips ANSI escape sequences that are meaningless noise to an LLM
|
|
74
|
+
# but can dominate terminal output payloads.
|
|
75
|
+
#
|
|
76
|
+
# @param str [String] UTF-8 string possibly containing escape codes
|
|
77
|
+
# @return [String] cleaned string
|
|
78
|
+
def self.strip_ansi(str)
|
|
79
|
+
str.gsub(ANSI_ESCAPE, "")
|
|
80
|
+
end
|
|
81
|
+
private_class_method :strip_ansi
|
|
82
|
+
|
|
83
|
+
# Strips C0 control characters (NUL, BEL, BS, CR, etc.) that carry
|
|
84
|
+
# no meaning for an LLM. Preserves newline (\n) and tab (\t).
|
|
85
|
+
#
|
|
86
|
+
# @param str [String] UTF-8 string possibly containing control chars
|
|
87
|
+
# @return [String] cleaned string
|
|
88
|
+
def self.strip_control_chars(str)
|
|
89
|
+
str.gsub(/[\x00-\x08\x0B-\x0D\x0E-\x1F\x7F]/, "")
|
|
36
90
|
end
|
|
91
|
+
private_class_method :strip_control_chars
|
|
37
92
|
|
|
38
93
|
# Subclasses override to transform the raw tool result.
|
|
39
94
|
#
|
data/app/models/event.rb
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
# @!attribute token_count
|
|
15
15
|
# @return [Integer] cached token count for this event's payload (0 until counted)
|
|
16
16
|
# @!attribute tool_use_id
|
|
17
|
-
# @return [String
|
|
17
|
+
# @return [String] ID correlating tool_call and tool_response events
|
|
18
|
+
# (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
|
|
19
|
+
# required for tool_call and tool_response events)
|
|
18
20
|
class Event < ApplicationRecord
|
|
19
21
|
include Event::Broadcasting
|
|
20
22
|
|
|
@@ -23,8 +25,12 @@ class Event < ApplicationRecord
|
|
|
23
25
|
CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
24
26
|
CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
|
|
25
27
|
THINK_TOOL = "think"
|
|
28
|
+
SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
|
|
26
29
|
PENDING_STATUS = "pending"
|
|
27
30
|
|
|
31
|
+
# Event types that require a tool_use_id to pair call with response.
|
|
32
|
+
TOOL_TYPES = %w[tool_call tool_response].freeze
|
|
33
|
+
|
|
28
34
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
29
35
|
|
|
30
36
|
# Heuristic: average bytes per token for English prose.
|
|
@@ -36,6 +42,8 @@ class Event < ApplicationRecord
|
|
|
36
42
|
validates :event_type, presence: true, inclusion: {in: TYPES}
|
|
37
43
|
validates :payload, presence: true
|
|
38
44
|
validates :timestamp, presence: true
|
|
45
|
+
# Anthropic requires every tool_use to have a matching tool_result with the same ID
|
|
46
|
+
validates :tool_use_id, presence: true, if: -> { event_type.in?(TOOL_TYPES) }
|
|
39
47
|
|
|
40
48
|
after_create :schedule_token_count, if: :llm_message?
|
|
41
49
|
|
|
@@ -60,6 +68,17 @@ class Event < ApplicationRecord
|
|
|
60
68
|
# @return [ActiveRecord::Relation]
|
|
61
69
|
scope :deliverable, -> { where(status: nil) }
|
|
62
70
|
|
|
71
|
+
# @!method self.excluding_spawn_events
|
|
72
|
+
# Excludes spawn_subagent/spawn_specialist tool_call and tool_response events.
|
|
73
|
+
# Used when building parent context for sub-agents — spawn events cause role
|
|
74
|
+
# confusion because the sub-agent sees sibling spawn results and mistakes
|
|
75
|
+
# itself for the parent.
|
|
76
|
+
# @return [ActiveRecord::Relation]
|
|
77
|
+
scope :excluding_spawn_events, -> {
|
|
78
|
+
where.not("event_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
|
|
79
|
+
TOOL_TYPES, SPAWN_TOOLS)
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
# Maps event_type to the Anthropic Messages API role.
|
|
64
83
|
# @return [String] "user" or "assistant"
|
|
65
84
|
def api_role
|
|
@@ -94,7 +113,7 @@ class Event < ApplicationRecord
|
|
|
94
113
|
#
|
|
95
114
|
# @return [Integer] estimated token count (at least 1)
|
|
96
115
|
def estimate_tokens
|
|
97
|
-
text = if event_type.in?(
|
|
116
|
+
text = if event_type.in?(TOOL_TYPES)
|
|
98
117
|
payload.to_json
|
|
99
118
|
else
|
|
100
119
|
payload["content"].to_s
|
data/app/models/session.rb
CHANGED
|
@@ -294,8 +294,8 @@ class Session < ApplicationRecord
|
|
|
294
294
|
# @return [Integer] number of synthetic responses created
|
|
295
295
|
def heal_orphaned_tool_calls!
|
|
296
296
|
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
297
|
-
responded_ids = events.where(event_type: "tool_response").
|
|
298
|
-
unresponded = events.where(event_type: "tool_call")
|
|
297
|
+
responded_ids = events.where(event_type: "tool_response").select(:tool_use_id)
|
|
298
|
+
unresponded = events.where(event_type: "tool_call")
|
|
299
299
|
.where.not(tool_use_id: responded_ids)
|
|
300
300
|
|
|
301
301
|
healed = 0
|
|
@@ -321,8 +321,35 @@ class Session < ApplicationRecord
|
|
|
321
321
|
healed
|
|
322
322
|
end
|
|
323
323
|
|
|
324
|
-
#
|
|
325
|
-
#
|
|
324
|
+
# Delivers a user message respecting the session's processing state.
|
|
325
|
+
#
|
|
326
|
+
# When idle, persists the event directly and enqueues {AgentRequestJob}
|
|
327
|
+
# to process it. When mid-turn ({#processing?}), emits a pending
|
|
328
|
+
# {Events::UserMessage} via {Events::Bus} so it queues until the
|
|
329
|
+
# current agent loop completes — preventing interleaving between
|
|
330
|
+
# tool_use/tool_result pairs.
|
|
331
|
+
#
|
|
332
|
+
# @param content [String] user message text
|
|
333
|
+
# @param bounce_back [Boolean] when true, passes +event_id+ to the job
|
|
334
|
+
# so failed LLM delivery triggers a {Events::BounceBack} (used by
|
|
335
|
+
# {SessionChannel#speak} for immediate-display messages)
|
|
336
|
+
# @return [void]
|
|
337
|
+
def enqueue_user_message(content, bounce_back: false)
|
|
338
|
+
if processing?
|
|
339
|
+
Events::Bus.emit(Events::UserMessage.new(
|
|
340
|
+
content: content, session_id: id,
|
|
341
|
+
status: Event::PENDING_STATUS
|
|
342
|
+
))
|
|
343
|
+
else
|
|
344
|
+
event = create_user_event(content)
|
|
345
|
+
job_args = bounce_back ? {event_id: event.id} : {}
|
|
346
|
+
AgentRequestJob.perform_later(id, **job_args)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Persists a user message event directly, bypassing the pending queue.
|
|
351
|
+
#
|
|
352
|
+
# Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
|
|
326
353
|
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
327
354
|
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
328
355
|
# messages — these callers own the persistence lifecycle.
|
|
@@ -501,9 +528,14 @@ class Session < ApplicationRecord
|
|
|
501
528
|
end
|
|
502
529
|
|
|
503
530
|
# Scopes parent events created before this session's fork point.
|
|
531
|
+
# Excludes spawn tool events — sub-agents don't need to see sibling
|
|
532
|
+
# spawn pairs, which cause role confusion (the sub-agent mistakes
|
|
533
|
+
# itself for the parent when it sees "Specialist @sibling spawned...").
|
|
504
534
|
# @return [ActiveRecord::Relation]
|
|
505
535
|
def parent_event_scope(include_pending)
|
|
506
|
-
scope = parent_session.events.context_events
|
|
536
|
+
scope = parent_session.events.context_events
|
|
537
|
+
.excluding_spawn_events
|
|
538
|
+
.where(created_at: ...created_at)
|
|
507
539
|
include_pending ? scope : scope.deliverable
|
|
508
540
|
end
|
|
509
541
|
|
data/lib/agent_loop.rb
CHANGED
|
@@ -129,6 +129,11 @@ class AgentLoop
|
|
|
129
129
|
# @return [Array<Class<Tools::Base>>]
|
|
130
130
|
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
|
|
131
131
|
|
|
132
|
+
# Tools that bypass {Session#granted_tools} filtering.
|
|
133
|
+
# The agent's reasoning depends on these regardless of task scope.
|
|
134
|
+
# @return [Array<Class<Tools::Base>>]
|
|
135
|
+
ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
|
|
136
|
+
|
|
132
137
|
# Name-to-class mapping for tool restriction validation and registry building.
|
|
133
138
|
# @return [Hash{String => Class<Tools::Base>}]
|
|
134
139
|
STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
|
|
@@ -194,12 +199,15 @@ class AgentLoop
|
|
|
194
199
|
|
|
195
200
|
# Standard tools available to this session.
|
|
196
201
|
# Returns all when {Session#granted_tools} is nil (no restriction).
|
|
197
|
-
# Returns only matching tools when granted_tools is an array
|
|
202
|
+
# Returns only matching tools when granted_tools is an array,
|
|
203
|
+
# always including {ALWAYS_GRANTED_TOOLS}.
|
|
198
204
|
#
|
|
199
205
|
# @return [Array<Class<Tools::Base>>] tool classes to register
|
|
200
206
|
def granted_standard_tools
|
|
201
|
-
|
|
207
|
+
granted = @session.granted_tools
|
|
208
|
+
return STANDARD_TOOLS unless granted
|
|
202
209
|
|
|
203
|
-
|
|
210
|
+
explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
|
|
211
|
+
(ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
|
|
204
212
|
end
|
|
205
213
|
end
|
data/lib/agents/registry.rb
CHANGED
data/lib/anima/settings.rb
CHANGED
|
@@ -236,6 +236,10 @@ module Anima
|
|
|
236
236
|
# @return [Integer]
|
|
237
237
|
def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
|
|
238
238
|
|
|
239
|
+
# Recency decay factor for search ranking (0.0 = pure relevance).
|
|
240
|
+
# @return [Float]
|
|
241
|
+
def recall_recency_decay = get("recall", "recency_decay")
|
|
242
|
+
|
|
239
243
|
private
|
|
240
244
|
|
|
241
245
|
# Reads a setting from the config file.
|
data/lib/anima/version.rb
CHANGED
|
@@ -6,16 +6,19 @@ module Events
|
|
|
6
6
|
# bidirectional @mention communication.
|
|
7
7
|
#
|
|
8
8
|
# **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
|
|
9
|
-
# the router
|
|
10
|
-
# with attribution prefix
|
|
9
|
+
# the router creates a {Events::UserMessage} in the parent session
|
|
10
|
+
# with attribution prefix. If the parent is idle, persists directly
|
|
11
|
+
# and wakes it via {AgentRequestJob}. If the parent is mid-turn,
|
|
12
|
+
# emits a pending message that is promoted after the current loop
|
|
13
|
+
# completes — same mechanism as {SessionChannel#speak}.
|
|
11
14
|
#
|
|
12
15
|
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
13
16
|
# containing `@name` mentions, the router persists the message in each
|
|
14
17
|
# matching child session and wakes them via {AgentRequestJob}.
|
|
15
18
|
#
|
|
16
|
-
# Both directions
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
+
# Both directions delegate to {Session#enqueue_user_message}, which
|
|
20
|
+
# respects the target session's processing state — persisting directly
|
|
21
|
+
# when idle, deferring via pending queue when mid-turn.
|
|
19
22
|
#
|
|
20
23
|
# This replaces the +return_result+ tool — sub-agents communicate
|
|
21
24
|
# through natural text messages instead of structured tool calls.
|
|
@@ -60,9 +63,8 @@ module Events
|
|
|
60
63
|
|
|
61
64
|
private
|
|
62
65
|
|
|
63
|
-
# Forwards a sub-agent's text message to its parent session
|
|
64
|
-
#
|
|
65
|
-
# up to process the message.
|
|
66
|
+
# Forwards a sub-agent's text message to its parent session
|
|
67
|
+
# via {Session#enqueue_user_message}.
|
|
66
68
|
#
|
|
67
69
|
# @param child [Session] the sub-agent session
|
|
68
70
|
# @param content [String] the sub-agent's message text
|
|
@@ -73,8 +75,7 @@ module Events
|
|
|
73
75
|
name = child.name || "agent-#{child.id}"
|
|
74
76
|
attributed = format(ATTRIBUTION_FORMAT, name, content)
|
|
75
77
|
|
|
76
|
-
parent.
|
|
77
|
-
AgentRequestJob.perform_later(parent.id)
|
|
78
|
+
parent.enqueue_user_message(attributed)
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
# Scans a parent agent's message for @mentions and routes the message
|
|
@@ -93,8 +94,7 @@ module Events
|
|
|
93
94
|
child = active_children[name]
|
|
94
95
|
next unless child
|
|
95
96
|
|
|
96
|
-
child.
|
|
97
|
-
AgentRequestJob.perform_later(child.id)
|
|
97
|
+
child.enqueue_user_message(content)
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -177,9 +177,12 @@ module LLM
|
|
|
177
177
|
# tool raises. Per the Anthropic tool-use protocol, every tool_use must
|
|
178
178
|
# have a matching tool_result; a missing result permanently corrupts the
|
|
179
179
|
# conversation history and breaks the session.
|
|
180
|
+
#
|
|
181
|
+
# Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
|
|
182
|
+
# ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
|
|
180
183
|
def execute_single_tool(tool_use, registry, session_id)
|
|
181
184
|
name = tool_use["name"]
|
|
182
|
-
id = tool_use["id"]
|
|
185
|
+
id = tool_use["id"] || SecureRandom.uuid
|
|
183
186
|
input = tool_use["input"] || {}
|
|
184
187
|
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
185
188
|
|
|
@@ -191,13 +194,7 @@ module LLM
|
|
|
191
194
|
session_id: session_id
|
|
192
195
|
))
|
|
193
196
|
|
|
194
|
-
result =
|
|
195
|
-
registry.execute(name, input)
|
|
196
|
-
rescue => error
|
|
197
|
-
Rails.logger.error("Tool #{name} raised #{error.class}: #{error.message}")
|
|
198
|
-
{error: "#{error.class}: #{error.message}"}
|
|
199
|
-
end
|
|
200
|
-
|
|
197
|
+
result = registry.execute(name, input)
|
|
201
198
|
result = ToolDecorator.call(name, result)
|
|
202
199
|
result_content = format_tool_result(result)
|
|
203
200
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
@@ -209,6 +206,23 @@ module LLM
|
|
|
209
206
|
))
|
|
210
207
|
|
|
211
208
|
{type: "tool_result", tool_use_id: id, content: result_content}
|
|
209
|
+
rescue => error
|
|
210
|
+
error_detail = "#{error.class}: #{error.message}"
|
|
211
|
+
Rails.logger.error("Tool #{name} raised #{error_detail}")
|
|
212
|
+
error_content = format_tool_result(error: error_detail)
|
|
213
|
+
|
|
214
|
+
# Emission can fail (e.g. encoding errors in ActionCable/SQLite),
|
|
215
|
+
# but losing the tool_result would permanently corrupt the session.
|
|
216
|
+
begin
|
|
217
|
+
Events::Bus.emit(Events::ToolResponse.new(
|
|
218
|
+
content: error_content, tool_name: name, tool_use_id: id,
|
|
219
|
+
success: false, session_id: session_id
|
|
220
|
+
))
|
|
221
|
+
rescue => emit_error
|
|
222
|
+
Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
212
226
|
end
|
|
213
227
|
|
|
214
228
|
# Creates a synthetic "Stopped by user" result for a tool that was not
|
|
@@ -220,7 +234,7 @@ module LLM
|
|
|
220
234
|
# @return [Hash] tool_result content block
|
|
221
235
|
def interrupt_tool(tool_use, session_id)
|
|
222
236
|
name = tool_use["name"]
|
|
223
|
-
id = tool_use["id"]
|
|
237
|
+
id = tool_use["id"] || SecureRandom.uuid
|
|
224
238
|
input = tool_use["input"] || {}
|
|
225
239
|
|
|
226
240
|
Events::Bus.emit(Events::ToolCall.new(
|
data/lib/mneme/search.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Mneme
|
|
|
21
21
|
# @!attribute session_id [Integer] the session owning this event
|
|
22
22
|
# @!attribute snippet [String] highlighted excerpt from the matching content
|
|
23
23
|
# @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
|
|
24
|
-
# @!attribute event_type [String]
|
|
24
|
+
# @!attribute event_type [String] friendly label: human, anima, system, or thought
|
|
25
25
|
Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
|
|
26
26
|
|
|
27
27
|
# Searches event history for the given terms.
|
|
@@ -38,6 +38,7 @@ module Mneme
|
|
|
38
38
|
@terms = sanitize_query(terms)
|
|
39
39
|
@session_id = session_id
|
|
40
40
|
@limit = limit
|
|
41
|
+
@recency_decay = Anima::Settings.recall_recency_decay
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# @return [Array<Result>] ranked by relevance (best first)
|
|
@@ -55,15 +56,23 @@ module Mneme
|
|
|
55
56
|
#
|
|
56
57
|
# @return [Array<Hash>] raw database rows
|
|
57
58
|
def execute_fts_query
|
|
58
|
-
if @session_id
|
|
59
|
-
|
|
59
|
+
sql = if @session_id
|
|
60
|
+
Arel.sql(scoped_sql, @recency_decay, @terms, @session_id, @limit)
|
|
60
61
|
else
|
|
61
|
-
|
|
62
|
+
Arel.sql(global_sql, @recency_decay, @terms, @limit)
|
|
62
63
|
end
|
|
64
|
+
|
|
65
|
+
connection.select_all(sql, "Mneme::Search").to_a
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
# FTS5 query across all sessions.
|
|
66
69
|
# Contentless FTS5 can't use snippet() — extract content from events directly.
|
|
70
|
+
#
|
|
71
|
+
# Ranking blends BM25 relevance with recency: rank is negative (more
|
|
72
|
+
# negative = better match), so dividing by a factor > 1 for older events
|
|
73
|
+
# moves them closer to zero (less relevant). At decay 0.3, a one-year-old
|
|
74
|
+
# result needs ~30% better keyword relevance to beat an identical match
|
|
75
|
+
# from today.
|
|
67
76
|
def global_sql
|
|
68
77
|
<<~SQL
|
|
69
78
|
SELECT
|
|
@@ -76,7 +85,7 @@ module Mneme
|
|
|
76
85
|
WHEN e.event_type = 'tool_call'
|
|
77
86
|
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
78
87
|
END AS snippet,
|
|
79
|
-
rank
|
|
88
|
+
rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
|
|
80
89
|
FROM events_fts
|
|
81
90
|
JOIN events e ON e.id = events_fts.rowid
|
|
82
91
|
WHERE events_fts MATCH ?
|
|
@@ -98,7 +107,7 @@ module Mneme
|
|
|
98
107
|
WHEN e.event_type = 'tool_call'
|
|
99
108
|
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
100
109
|
END AS snippet,
|
|
101
|
-
rank
|
|
110
|
+
rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
|
|
102
111
|
FROM events_fts
|
|
103
112
|
JOIN events e ON e.id = events_fts.rowid
|
|
104
113
|
WHERE events_fts MATCH ?
|
|
@@ -108,17 +117,25 @@ module Mneme
|
|
|
108
117
|
SQL
|
|
109
118
|
end
|
|
110
119
|
|
|
120
|
+
FRIENDLY_EVENT_TYPES = {
|
|
121
|
+
"user_message" => "human",
|
|
122
|
+
"agent_message" => "anima",
|
|
123
|
+
"system_message" => "system",
|
|
124
|
+
"tool_call" => "thought"
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
111
127
|
# Builds a Result from a raw database row.
|
|
112
128
|
#
|
|
113
129
|
# @param row [Hash]
|
|
114
130
|
# @return [Result]
|
|
115
131
|
def build_result(row)
|
|
132
|
+
raw_type = row["event_type"]
|
|
116
133
|
Result.new(
|
|
117
134
|
event_id: row["event_id"],
|
|
118
135
|
session_id: row["session_id"],
|
|
119
136
|
snippet: row["snippet"],
|
|
120
137
|
rank: row["rank"],
|
|
121
|
-
event_type:
|
|
138
|
+
event_type: FRIENDLY_EVENT_TYPES.fetch(raw_type, raw_type)
|
|
122
139
|
)
|
|
123
140
|
end
|
|
124
141
|
|
data/lib/shell_session.rb
CHANGED
|
@@ -390,10 +390,11 @@ class ShellSession
|
|
|
390
390
|
|
|
391
391
|
def truncate(output)
|
|
392
392
|
max_bytes = @max_output_bytes
|
|
393
|
+
output = output.dup.force_encoding("UTF-8").scrub
|
|
394
|
+
|
|
393
395
|
return output if output.bytesize <= max_bytes
|
|
394
396
|
|
|
395
397
|
output.byteslice(0, max_bytes)
|
|
396
|
-
.force_encoding("UTF-8")
|
|
397
398
|
.scrub +
|
|
398
399
|
"\n\n[Truncated: output exceeded #{max_bytes} bytes]"
|
|
399
400
|
end
|
data/lib/skills/registry.rb
CHANGED
data/lib/tools/bash.rb
CHANGED
|
@@ -6,19 +6,42 @@ module Tools
|
|
|
6
6
|
# conversation. Output is truncated and timeouts are enforced by the
|
|
7
7
|
# underlying session.
|
|
8
8
|
#
|
|
9
|
+
# Supports two modes:
|
|
10
|
+
# - Single command via +command+ (string) — backward compatible
|
|
11
|
+
# - Batch via +commands+ (array) with +mode+ controlling error handling
|
|
12
|
+
#
|
|
9
13
|
# @see ShellSession#run
|
|
10
14
|
class Bash < Base
|
|
11
15
|
def self.tool_name = "bash"
|
|
12
16
|
|
|
13
|
-
def self.description
|
|
17
|
+
def self.description
|
|
18
|
+
<<~DESC.squish
|
|
19
|
+
Execute a bash command. Working directory and environment persist across calls within a conversation.
|
|
20
|
+
Accepts either `command` (string) for a single command, or `commands` (array of strings) to run
|
|
21
|
+
multiple commands as a batch — each command gets its own timeout and result. Batch `mode` controls
|
|
22
|
+
error handling: "sequential" (default) stops on the first failure, "parallel" runs all regardless.
|
|
23
|
+
DESC
|
|
24
|
+
end
|
|
14
25
|
|
|
15
26
|
def self.input_schema
|
|
16
27
|
{
|
|
17
28
|
type: "object",
|
|
18
29
|
properties: {
|
|
19
|
-
command: {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
command: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "The bash command to execute"
|
|
33
|
+
},
|
|
34
|
+
commands: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: {type: "string"},
|
|
37
|
+
description: "Array of bash commands to execute as a batch. Each runs independently with its own timeout and result."
|
|
38
|
+
},
|
|
39
|
+
mode: {
|
|
40
|
+
type: "string",
|
|
41
|
+
enum: ["sequential", "parallel"],
|
|
42
|
+
description: 'Batch error handling: "sequential" (default) stops on first non-zero exit; "parallel" runs all commands regardless of failures.'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
22
45
|
}
|
|
23
46
|
end
|
|
24
47
|
|
|
@@ -33,16 +56,76 @@ module Tools
|
|
|
33
56
|
# @return [String] formatted output with stdout, stderr, and exit code
|
|
34
57
|
# @return [Hash] with :error key on failure
|
|
35
58
|
def execute(input)
|
|
36
|
-
|
|
59
|
+
timeout = input["timeout"]
|
|
60
|
+
has_command = input.key?("command")
|
|
61
|
+
has_commands = input.key?("commands")
|
|
62
|
+
|
|
63
|
+
if has_command && has_commands
|
|
64
|
+
{error: "Provide either 'command' or 'commands', not both"}
|
|
65
|
+
elsif has_commands
|
|
66
|
+
execute_batch(input["commands"], mode: input.fetch("mode", "sequential"), timeout: timeout)
|
|
67
|
+
elsif has_command
|
|
68
|
+
execute_single(input["command"], timeout: timeout)
|
|
69
|
+
else
|
|
70
|
+
{error: "Either 'command' (string) or 'commands' (array of strings) is required"}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Executes a single command — the original code path, preserved for backward compatibility.
|
|
77
|
+
def execute_single(command, timeout: nil)
|
|
78
|
+
command = command.to_s
|
|
37
79
|
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
38
80
|
|
|
39
|
-
result = @shell_session.run(command, timeout:
|
|
81
|
+
result = @shell_session.run(command, timeout: timeout)
|
|
40
82
|
return result if result.key?(:error)
|
|
41
83
|
|
|
42
84
|
format_result(result[:stdout], result[:stderr], result[:exit_code])
|
|
43
85
|
end
|
|
44
86
|
|
|
45
|
-
|
|
87
|
+
# Executes an array of commands, returning a combined result string.
|
|
88
|
+
# @param commands [Array<String>] commands to execute
|
|
89
|
+
# @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
|
|
90
|
+
# @param timeout [Integer, nil] per-command timeout override
|
|
91
|
+
# @return [String] combined results with per-command headers
|
|
92
|
+
# @return [Hash] with :error key if commands array is invalid
|
|
93
|
+
def execute_batch(commands, mode:, timeout: nil)
|
|
94
|
+
return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
|
|
95
|
+
|
|
96
|
+
total = commands.size
|
|
97
|
+
results = []
|
|
98
|
+
failed = false
|
|
99
|
+
|
|
100
|
+
commands.each_with_index do |command, index|
|
|
101
|
+
position = "[#{index + 1}/#{total}]"
|
|
102
|
+
|
|
103
|
+
if failed && mode == "sequential"
|
|
104
|
+
results << "#{position} $ #{command}\n(skipped)"
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
command = command.to_s
|
|
109
|
+
if command.strip.empty?
|
|
110
|
+
results << "#{position} $ (blank)\n(skipped — blank command)"
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
result = @shell_session.run(command, timeout: timeout)
|
|
115
|
+
|
|
116
|
+
if result.key?(:error)
|
|
117
|
+
results << "#{position} $ #{command}\n#{result[:error]}"
|
|
118
|
+
failed = true
|
|
119
|
+
else
|
|
120
|
+
exit_code = result[:exit_code]
|
|
121
|
+
output = format_result(result[:stdout], result[:stderr], exit_code)
|
|
122
|
+
results << "#{position} $ #{command}\n#{output}"
|
|
123
|
+
failed = true if exit_code != 0
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
results.join("\n\n")
|
|
128
|
+
end
|
|
46
129
|
|
|
47
130
|
def format_result(stdout, stderr, exit_code)
|
|
48
131
|
parts = []
|
data/lib/workflows/registry.rb
CHANGED
data/templates/config.toml
CHANGED
|
@@ -161,3 +161,9 @@ budget_fraction = 0.05
|
|
|
161
161
|
|
|
162
162
|
# Maximum tokens per individual recall snippet.
|
|
163
163
|
max_snippet_tokens = 512
|
|
164
|
+
|
|
165
|
+
# Recency decay factor for search ranking. Blends FTS5 relevance with event
|
|
166
|
+
# age so recent memories win ties. 0.0 = pure relevance, higher = stronger
|
|
167
|
+
# recency bias. At 0.3 a one-year-old result needs ~30% better keyword
|
|
168
|
+
# relevance to beat an identical match from today.
|
|
169
|
+
recency_decay = 0.3
|