anima-core 1.1.2 → 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 +6 -0
- data/app/channels/session_channel.rb +2 -6
- 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 +5 -2
- data/lib/mneme/search.rb +24 -7
- 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,11 @@ 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"
|
|
83
89
|
# encode_utf8 is descriptive — the digit triggers a false positive.
|
|
84
90
|
UncommunicativeMethodName:
|
|
85
91
|
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
|
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
|
|
|
@@ -231,7 +234,7 @@ module LLM
|
|
|
231
234
|
# @return [Hash] tool_result content block
|
|
232
235
|
def interrupt_tool(tool_use, session_id)
|
|
233
236
|
name = tool_use["name"]
|
|
234
|
-
id = tool_use["id"]
|
|
237
|
+
id = tool_use["id"] || SecureRandom.uuid
|
|
235
238
|
input = tool_use["input"] || {}
|
|
236
239
|
|
|
237
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/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
|