anima-core 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +8 -0
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +1 -1
- data/app/channels/session_channel.rb +46 -49
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +2 -2
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +3 -3
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +20 -20
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +4 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +132 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/session.rb +232 -192
- data/app/models/snapshot.rb +25 -25
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/lib/agent_loop.rb +17 -9
- data/lib/agents/registry.rb +1 -1
- data/lib/analytical_brain/runner.rb +35 -35
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/settings.rb +19 -4
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +7 -7
- data/lib/events/subscribers/subagent_message_router.rb +12 -12
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/llm/client.rb +5 -2
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +55 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/bash.rb +82 -7
- data/lib/tools/edit.rb +4 -6
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +4 -4
- data/lib/tools/registry.rb +1 -1
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/spawn_specialist.rb +12 -23
- data/lib/tools/spawn_subagent.rb +9 -19
- data/lib/tools/subagent_prompts.rb +0 -2
- data/lib/tools/think.rb +3 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +3 -3
- data/lib/tui/cable_client.rb +3 -3
- data/lib/tui/message_store.rb +37 -37
- data/lib/tui/screens/chat.rb +27 -15
- data/lib/workflows/registry.rb +1 -1
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +21 -4
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +10 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -110
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
data/app/models/snapshot.rb
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# A persisted summary of conversation context created by Mneme before
|
|
4
|
-
#
|
|
4
|
+
# messages evict from the viewport. Snapshots capture the "gist" of what
|
|
5
5
|
# happened so the agent retains awareness of past context.
|
|
6
6
|
#
|
|
7
|
-
# Level 1 snapshots are created from raw
|
|
7
|
+
# Level 1 snapshots are created from raw messages (conversation + thinks).
|
|
8
8
|
# Level 2 snapshots compress multiple Level 1 snapshots (days/weeks scale).
|
|
9
|
-
# Both levels use the same
|
|
9
|
+
# Both levels use the same message ID range tracking — an L2 snapshot's range
|
|
10
10
|
# is the union of its constituent L1 snapshots.
|
|
11
11
|
#
|
|
12
12
|
# @!attribute text
|
|
13
13
|
# @return [String] the summary text generated by Mneme
|
|
14
|
-
# @!attribute
|
|
15
|
-
# @return [Integer] first
|
|
16
|
-
# @!attribute
|
|
17
|
-
# @return [Integer] last
|
|
14
|
+
# @!attribute from_message_id
|
|
15
|
+
# @return [Integer] first message ID covered by this snapshot
|
|
16
|
+
# @!attribute to_message_id
|
|
17
|
+
# @return [Integer] last message ID covered by this snapshot
|
|
18
18
|
# @!attribute level
|
|
19
|
-
# @return [Integer] compression level (1 = from raw
|
|
19
|
+
# @return [Integer] compression level (1 = from raw messages, 2 = from L1 snapshots)
|
|
20
20
|
# @!attribute token_count
|
|
21
21
|
# @return [Integer] cached token count of the summary text
|
|
22
22
|
class Snapshot < ApplicationRecord
|
|
@@ -27,34 +27,34 @@ class Snapshot < ApplicationRecord
|
|
|
27
27
|
MAX_TEXT_BYTES = 32_768
|
|
28
28
|
|
|
29
29
|
validates :text, presence: true, length: {maximum: MAX_TEXT_BYTES}
|
|
30
|
-
validates :
|
|
31
|
-
validates :
|
|
30
|
+
validates :from_message_id, presence: true
|
|
31
|
+
validates :to_message_id, presence: true
|
|
32
32
|
validates :level, presence: true, numericality: {greater_than: 0}
|
|
33
33
|
validates :token_count, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
|
|
34
|
-
validate :
|
|
34
|
+
validate :from_message_id_not_after_to_message_id
|
|
35
35
|
|
|
36
36
|
scope :for_level, ->(level) { where(level: level) }
|
|
37
|
-
scope :chronological, -> { order(:
|
|
37
|
+
scope :chronological, -> { order(:from_message_id) }
|
|
38
38
|
|
|
39
|
-
# L1 snapshots whose
|
|
39
|
+
# L1 snapshots whose message range is NOT fully contained within any L2 snapshot.
|
|
40
40
|
# Used to determine which L1 snapshots are still "live" in the viewport.
|
|
41
41
|
scope :not_covered_by_l2, -> {
|
|
42
42
|
where.not(
|
|
43
43
|
"EXISTS (SELECT 1 FROM snapshots l2 " \
|
|
44
44
|
"WHERE l2.session_id = snapshots.session_id " \
|
|
45
45
|
"AND l2.level = 2 " \
|
|
46
|
-
"AND l2.
|
|
47
|
-
"AND l2.
|
|
46
|
+
"AND l2.from_message_id <= snapshots.from_message_id " \
|
|
47
|
+
"AND l2.to_message_id >= snapshots.to_message_id)"
|
|
48
48
|
)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
# Snapshots whose source
|
|
52
|
-
# A snapshot is visible when its entire
|
|
53
|
-
#
|
|
51
|
+
# Snapshots whose source messages have fully evicted from the sliding window.
|
|
52
|
+
# A snapshot is visible when its entire message range precedes the first
|
|
53
|
+
# message currently in the viewport.
|
|
54
54
|
#
|
|
55
|
-
# @param
|
|
56
|
-
scope :
|
|
57
|
-
where("
|
|
55
|
+
# @param first_message_id [Integer] the first message ID in the sliding window
|
|
56
|
+
scope :source_messages_evicted, ->(first_message_id) {
|
|
57
|
+
where("to_message_id < ?", first_message_id)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
# @return [Integer] token cost, using cached count or heuristic estimate
|
|
@@ -64,13 +64,13 @@ class Snapshot < ApplicationRecord
|
|
|
64
64
|
|
|
65
65
|
private
|
|
66
66
|
|
|
67
|
-
def
|
|
68
|
-
return unless
|
|
69
|
-
errors.add(:
|
|
67
|
+
def from_message_id_not_after_to_message_id
|
|
68
|
+
return unless from_message_id && to_message_id
|
|
69
|
+
errors.add(:from_message_id, "must be <= to_message_id") if from_message_id > to_message_id
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# @return [Integer] estimated token count (at least 1)
|
|
73
73
|
def estimate_tokens
|
|
74
|
-
[(text.bytesize /
|
|
74
|
+
[(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
75
75
|
end
|
|
76
76
|
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Renames the persistence layer from Event to Message.
|
|
4
|
+
#
|
|
5
|
+
# Events are ephemeral bus signals; Messages are persisted records of what
|
|
6
|
+
# was said, by whom, in which conversation. The Anthropic API calls them
|
|
7
|
+
# "messages" — this rename aligns the codebase with the agent's vocabulary.
|
|
8
|
+
#
|
|
9
|
+
# See: thoughts/shared/notes/2026-03-24/narrative-design-for-agent-prompts.md
|
|
10
|
+
class RenameEventToMessage < ActiveRecord::Migration[8.1]
|
|
11
|
+
def up
|
|
12
|
+
# --- FTS5: drop triggers and virtual table (references old table name) ---
|
|
13
|
+
execute "DROP TRIGGER IF EXISTS events_fts_insert"
|
|
14
|
+
execute "DROP TRIGGER IF EXISTS events_fts_delete"
|
|
15
|
+
execute "DROP TABLE IF EXISTS events_fts"
|
|
16
|
+
|
|
17
|
+
# --- Main table: events → messages ---
|
|
18
|
+
rename_table :events, :messages
|
|
19
|
+
rename_column :messages, :event_type, :message_type
|
|
20
|
+
|
|
21
|
+
# --- Pinned events → pinned messages ---
|
|
22
|
+
rename_table :pinned_events, :pinned_messages
|
|
23
|
+
rename_column :pinned_messages, :event_id, :message_id
|
|
24
|
+
|
|
25
|
+
# --- Join table: goal_pinned_events → goal_pinned_messages ---
|
|
26
|
+
rename_table :goal_pinned_events, :goal_pinned_messages
|
|
27
|
+
rename_column :goal_pinned_messages, :pinned_event_id, :pinned_message_id
|
|
28
|
+
|
|
29
|
+
# --- Snapshots: event range columns ---
|
|
30
|
+
rename_column :snapshots, :from_event_id, :from_message_id
|
|
31
|
+
rename_column :snapshots, :to_event_id, :to_message_id
|
|
32
|
+
|
|
33
|
+
# --- Sessions: event pointer columns ---
|
|
34
|
+
rename_column :sessions, :mneme_boundary_event_id, :mneme_boundary_message_id
|
|
35
|
+
rename_column :sessions, :mneme_snapshot_first_event_id, :mneme_snapshot_first_message_id
|
|
36
|
+
rename_column :sessions, :mneme_snapshot_last_event_id, :mneme_snapshot_last_message_id
|
|
37
|
+
rename_column :sessions, :recalled_event_ids, :recalled_message_ids
|
|
38
|
+
rename_column :sessions, :viewport_event_ids, :viewport_message_ids
|
|
39
|
+
|
|
40
|
+
# --- Recreate FTS5 virtual table with new names ---
|
|
41
|
+
execute <<~SQL
|
|
42
|
+
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
43
|
+
searchable_text,
|
|
44
|
+
content='',
|
|
45
|
+
contentless_delete=1,
|
|
46
|
+
tokenize='porter unicode61'
|
|
47
|
+
);
|
|
48
|
+
SQL
|
|
49
|
+
|
|
50
|
+
execute <<~SQL
|
|
51
|
+
INSERT INTO messages_fts(rowid, searchable_text)
|
|
52
|
+
SELECT m.id,
|
|
53
|
+
CASE
|
|
54
|
+
WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
55
|
+
THEN json_extract(m.payload, '$.content')
|
|
56
|
+
WHEN m.message_type = 'tool_call' AND json_extract(m.payload, '$.tool_name') = 'think'
|
|
57
|
+
THEN json_extract(m.payload, '$.tool_input.thoughts')
|
|
58
|
+
END
|
|
59
|
+
FROM messages m
|
|
60
|
+
WHERE (m.message_type IN ('user_message', 'agent_message', 'system_message'))
|
|
61
|
+
OR (m.message_type = 'tool_call' AND json_extract(m.payload, '$.tool_name') = 'think');
|
|
62
|
+
SQL
|
|
63
|
+
|
|
64
|
+
execute <<~SQL
|
|
65
|
+
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages
|
|
66
|
+
WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
67
|
+
OR (NEW.message_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
|
|
68
|
+
BEGIN
|
|
69
|
+
INSERT INTO messages_fts(rowid, searchable_text)
|
|
70
|
+
VALUES (
|
|
71
|
+
NEW.id,
|
|
72
|
+
CASE
|
|
73
|
+
WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
74
|
+
THEN json_extract(NEW.payload, '$.content')
|
|
75
|
+
WHEN NEW.message_type = 'tool_call'
|
|
76
|
+
THEN json_extract(NEW.payload, '$.tool_input.thoughts')
|
|
77
|
+
END
|
|
78
|
+
);
|
|
79
|
+
END;
|
|
80
|
+
SQL
|
|
81
|
+
|
|
82
|
+
execute <<~SQL
|
|
83
|
+
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages
|
|
84
|
+
WHEN OLD.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
85
|
+
OR (OLD.message_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
|
|
86
|
+
BEGIN
|
|
87
|
+
DELETE FROM messages_fts WHERE rowid = OLD.id;
|
|
88
|
+
END;
|
|
89
|
+
SQL
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def down
|
|
93
|
+
# --- FTS5: drop new triggers and table ---
|
|
94
|
+
execute "DROP TRIGGER IF EXISTS messages_fts_insert"
|
|
95
|
+
execute "DROP TRIGGER IF EXISTS messages_fts_delete"
|
|
96
|
+
execute "DROP TABLE IF EXISTS messages_fts"
|
|
97
|
+
|
|
98
|
+
# --- Sessions: restore event pointer columns ---
|
|
99
|
+
rename_column :sessions, :mneme_boundary_message_id, :mneme_boundary_event_id
|
|
100
|
+
rename_column :sessions, :mneme_snapshot_first_message_id, :mneme_snapshot_first_event_id
|
|
101
|
+
rename_column :sessions, :mneme_snapshot_last_message_id, :mneme_snapshot_last_event_id
|
|
102
|
+
rename_column :sessions, :recalled_message_ids, :recalled_event_ids
|
|
103
|
+
rename_column :sessions, :viewport_message_ids, :viewport_event_ids
|
|
104
|
+
|
|
105
|
+
# --- Snapshots: restore event range columns ---
|
|
106
|
+
rename_column :snapshots, :from_message_id, :from_event_id
|
|
107
|
+
rename_column :snapshots, :to_message_id, :to_event_id
|
|
108
|
+
|
|
109
|
+
# --- Join table: goal_pinned_messages → goal_pinned_events ---
|
|
110
|
+
rename_column :goal_pinned_messages, :pinned_message_id, :pinned_event_id
|
|
111
|
+
rename_table :goal_pinned_messages, :goal_pinned_events
|
|
112
|
+
|
|
113
|
+
# --- Pinned messages → pinned events ---
|
|
114
|
+
rename_column :pinned_messages, :message_id, :event_id
|
|
115
|
+
rename_table :pinned_messages, :pinned_events
|
|
116
|
+
|
|
117
|
+
# --- Main table: messages → events ---
|
|
118
|
+
rename_column :messages, :message_type, :event_type
|
|
119
|
+
rename_table :messages, :events
|
|
120
|
+
|
|
121
|
+
# --- Recreate original FTS5 ---
|
|
122
|
+
execute <<~SQL
|
|
123
|
+
CREATE VIRTUAL TABLE events_fts USING fts5(
|
|
124
|
+
searchable_text,
|
|
125
|
+
content='',
|
|
126
|
+
contentless_delete=1,
|
|
127
|
+
tokenize='porter unicode61'
|
|
128
|
+
);
|
|
129
|
+
SQL
|
|
130
|
+
|
|
131
|
+
execute <<~SQL
|
|
132
|
+
INSERT INTO events_fts(rowid, searchable_text)
|
|
133
|
+
SELECT e.id,
|
|
134
|
+
CASE
|
|
135
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
136
|
+
THEN json_extract(e.payload, '$.content')
|
|
137
|
+
WHEN e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think'
|
|
138
|
+
THEN json_extract(e.payload, '$.tool_input.thoughts')
|
|
139
|
+
END
|
|
140
|
+
FROM events e
|
|
141
|
+
WHERE (e.event_type IN ('user_message', 'agent_message', 'system_message'))
|
|
142
|
+
OR (e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think');
|
|
143
|
+
SQL
|
|
144
|
+
|
|
145
|
+
execute <<~SQL
|
|
146
|
+
CREATE TRIGGER events_fts_insert AFTER INSERT ON events
|
|
147
|
+
WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
148
|
+
OR (NEW.event_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
|
|
149
|
+
BEGIN
|
|
150
|
+
INSERT INTO events_fts(rowid, searchable_text)
|
|
151
|
+
VALUES (
|
|
152
|
+
NEW.id,
|
|
153
|
+
CASE
|
|
154
|
+
WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
155
|
+
THEN json_extract(NEW.payload, '$.content')
|
|
156
|
+
WHEN NEW.event_type = 'tool_call'
|
|
157
|
+
THEN json_extract(NEW.payload, '$.tool_input.thoughts')
|
|
158
|
+
END
|
|
159
|
+
);
|
|
160
|
+
END;
|
|
161
|
+
SQL
|
|
162
|
+
|
|
163
|
+
execute <<~SQL
|
|
164
|
+
CREATE TRIGGER events_fts_delete AFTER DELETE ON events
|
|
165
|
+
WHEN OLD.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
166
|
+
OR (OLD.event_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
|
|
167
|
+
BEGIN
|
|
168
|
+
DELETE FROM events_fts WHERE rowid = OLD.id;
|
|
169
|
+
END;
|
|
170
|
+
SQL
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/agent_loop.rb
CHANGED
|
@@ -42,7 +42,7 @@ class AgentLoop
|
|
|
42
42
|
|
|
43
43
|
# Runs the agent loop for a single user input.
|
|
44
44
|
#
|
|
45
|
-
# Persists the user
|
|
45
|
+
# Persists the user message directly (the global Persister skips
|
|
46
46
|
# non-pending user messages because {AgentRequestJob} owns their
|
|
47
47
|
# lifecycle). Then emits a bus notification and delegates to {#run}.
|
|
48
48
|
# On error emits {Events::AgentMessage} with the error text.
|
|
@@ -53,7 +53,7 @@ class AgentLoop
|
|
|
53
53
|
text = input.to_s.strip
|
|
54
54
|
return if text.empty?
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
persist_user_message(text)
|
|
57
57
|
Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
|
|
58
58
|
run
|
|
59
59
|
rescue => error
|
|
@@ -129,15 +129,20 @@ 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
|
|
135
140
|
|
|
136
141
|
private
|
|
137
142
|
|
|
138
|
-
# @see Session#
|
|
139
|
-
def
|
|
140
|
-
@session.
|
|
143
|
+
# @see Session#create_user_message
|
|
144
|
+
def persist_user_message(content)
|
|
145
|
+
@session.create_user_message(content)
|
|
141
146
|
end
|
|
142
147
|
|
|
143
148
|
# Assembles LLM options (system prompt, environment context).
|
|
@@ -170,7 +175,7 @@ class AgentLoop
|
|
|
170
175
|
unless @session.sub_agent?
|
|
171
176
|
registry.register(Tools::SpawnSubagent)
|
|
172
177
|
registry.register(Tools::SpawnSpecialist)
|
|
173
|
-
registry.register(Tools::
|
|
178
|
+
registry.register(Tools::OpenIssue)
|
|
174
179
|
end
|
|
175
180
|
|
|
176
181
|
register_mcp_tools(registry)
|
|
@@ -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
|
@@ -27,7 +27,7 @@ module AnalyticalBrain
|
|
|
27
27
|
──────────────────────────────
|
|
28
28
|
SESSION NAMING
|
|
29
29
|
──────────────────────────────
|
|
30
|
-
|
|
30
|
+
Name the session when the topic becomes clear. Rename if it shifts.
|
|
31
31
|
Format: one emoji + 1-3 descriptive words.
|
|
32
32
|
PROMPT
|
|
33
33
|
tools: [Tools::RenameSession]
|
|
@@ -38,10 +38,10 @@ module AnalyticalBrain
|
|
|
38
38
|
──────────────────────────────
|
|
39
39
|
SUB-AGENT NAMING
|
|
40
40
|
──────────────────────────────
|
|
41
|
-
|
|
41
|
+
Give this sub-agent a memorable nickname based on its task.
|
|
42
42
|
Format: 1-3 lowercase words joined by hyphens (e.g. "loop-sleuth", "api-scout").
|
|
43
|
-
Evocative
|
|
44
|
-
|
|
43
|
+
Evocative, fun, easy to type after @.
|
|
44
|
+
One nickname per call. If taken, pick another — no numeric suffixes.
|
|
45
45
|
PROMPT
|
|
46
46
|
tools: [Tools::AssignNickname]
|
|
47
47
|
),
|
|
@@ -51,8 +51,8 @@ module AnalyticalBrain
|
|
|
51
51
|
──────────────────────────────
|
|
52
52
|
SKILL MANAGEMENT
|
|
53
53
|
──────────────────────────────
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
Activate skills when the conversation enters their domain.
|
|
55
|
+
Deactivate when the agent moves to a different domain.
|
|
56
56
|
Multiple skills can be active at once.
|
|
57
57
|
PROMPT
|
|
58
58
|
tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
|
|
@@ -63,11 +63,11 @@ module AnalyticalBrain
|
|
|
63
63
|
──────────────────────────────
|
|
64
64
|
WORKFLOW MANAGEMENT
|
|
65
65
|
──────────────────────────────
|
|
66
|
-
|
|
67
|
-
Read the returned content and use judgment to create
|
|
66
|
+
Activate a workflow when the user starts a multi-step task that matches one.
|
|
67
|
+
Read the returned content and use judgment to create goals — not a mechanical 1:1 mapping.
|
|
68
68
|
Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
|
|
69
|
-
|
|
70
|
-
Only one workflow
|
|
69
|
+
Deactivate the workflow when it completes or the user shifts focus.
|
|
70
|
+
Only one workflow active at a time — activating a new one replaces the previous.
|
|
71
71
|
PROMPT
|
|
72
72
|
tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
|
|
73
73
|
),
|
|
@@ -77,11 +77,11 @@ module AnalyticalBrain
|
|
|
77
77
|
──────────────────────────────
|
|
78
78
|
GOAL TRACKING
|
|
79
79
|
──────────────────────────────
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
Create a root goal when the user starts a multi-step task.
|
|
81
|
+
Break it into sub-goals as the plan becomes clear.
|
|
82
|
+
Refine goal wording as understanding evolves.
|
|
83
|
+
Mark goals complete when the agent finishes the work they describe.
|
|
84
|
+
Completing a root goal cascades — all sub-goals are finished too.
|
|
85
85
|
Never duplicate an existing goal — check the active goals list first.
|
|
86
86
|
PROMPT
|
|
87
87
|
tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
|
|
@@ -89,17 +89,17 @@ module AnalyticalBrain
|
|
|
89
89
|
}.freeze
|
|
90
90
|
|
|
91
91
|
BASE_PROMPT = <<~PROMPT
|
|
92
|
-
You
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
You manage context for the main agent — skills, goals, workflows, and session names.
|
|
93
|
+
Watch the conversation and act when context needs updating.
|
|
94
|
+
Communicate only through tool calls — never output text.
|
|
95
95
|
PROMPT
|
|
96
96
|
|
|
97
97
|
COMPLETION_PROMPT = <<~PROMPT
|
|
98
98
|
──────────────────────────────
|
|
99
99
|
COMPLETION
|
|
100
100
|
──────────────────────────────
|
|
101
|
-
|
|
102
|
-
If nothing needs
|
|
101
|
+
Always finish with everything_is_ready.
|
|
102
|
+
If nothing needs attention, call it immediately.
|
|
103
103
|
PROMPT
|
|
104
104
|
|
|
105
105
|
# Which responsibilities activate for each session type.
|
|
@@ -118,7 +118,7 @@ module AnalyticalBrain
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
# Runs the analytical brain loop. Builds context from the session's
|
|
121
|
-
# recent
|
|
121
|
+
# recent messages, calls the LLM with the session-appropriate tool set,
|
|
122
122
|
# and executes any tool calls against the session.
|
|
123
123
|
#
|
|
124
124
|
# Events emitted during tool execution are not persisted — the phantom
|
|
@@ -130,12 +130,12 @@ module AnalyticalBrain
|
|
|
130
130
|
messages = build_messages
|
|
131
131
|
sid = @session.id
|
|
132
132
|
if messages.empty?
|
|
133
|
-
log.debug("session=#{sid} — no
|
|
133
|
+
log.debug("session=#{sid} — no messages, skipping")
|
|
134
134
|
return
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
system = build_system_prompt
|
|
138
|
-
log.info("session=#{sid} — running (#{
|
|
138
|
+
log.info("session=#{sid} — running (#{recent_messages.size} messages)")
|
|
139
139
|
log.debug("system prompt:\n#{system}")
|
|
140
140
|
log.debug("user message:\n#{messages.first[:content]}")
|
|
141
141
|
|
|
@@ -162,18 +162,18 @@ module AnalyticalBrain
|
|
|
162
162
|
active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
-
# Builds a condensed transcript of recent
|
|
165
|
+
# Builds a condensed transcript of recent messages as a single user message.
|
|
166
166
|
# The framing differs by session type:
|
|
167
167
|
#
|
|
168
168
|
# * **Parent:** "The main session is working on this: [transcript]"
|
|
169
169
|
# * **Child:** "A sub-agent has been spawned with this task: [transcript]"
|
|
170
170
|
#
|
|
171
|
-
# @return [Array<Hash>] single-element messages array, or empty if no
|
|
171
|
+
# @return [Array<Hash>] single-element messages array, or empty if no messages
|
|
172
172
|
def build_messages
|
|
173
|
-
|
|
174
|
-
return [] if
|
|
173
|
+
messages = recent_messages
|
|
174
|
+
return [] if messages.empty?
|
|
175
175
|
|
|
176
|
-
transcript =
|
|
176
|
+
transcript = messages.filter_map { |msg| MessageDecorator.for(msg)&.render("brain") }.join("\n")
|
|
177
177
|
|
|
178
178
|
if @session.sub_agent?
|
|
179
179
|
build_child_message(transcript)
|
|
@@ -189,7 +189,7 @@ module AnalyticalBrain
|
|
|
189
189
|
#{transcript}
|
|
190
190
|
```
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
Review and take any needed actions, then call everything_is_ready.
|
|
193
193
|
MSG
|
|
194
194
|
[{role: "user", content: content}]
|
|
195
195
|
end
|
|
@@ -201,17 +201,17 @@ module AnalyticalBrain
|
|
|
201
201
|
#{transcript}
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
Assign a
|
|
204
|
+
Assign a nickname, activate relevant skills, then call everything_is_ready.
|
|
205
205
|
MSG
|
|
206
206
|
[{role: "user", content: content}]
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
-
# @return [Array<
|
|
210
|
-
def
|
|
211
|
-
@session.
|
|
212
|
-
.
|
|
209
|
+
# @return [Array<Message>] most recent messages in chronological order
|
|
210
|
+
def recent_messages
|
|
211
|
+
@session.messages
|
|
212
|
+
.context_messages
|
|
213
213
|
.reorder(id: :desc)
|
|
214
|
-
.limit(Anima::Settings.
|
|
214
|
+
.limit(Anima::Settings.analytical_brain_message_window)
|
|
215
215
|
.to_a
|
|
216
216
|
.reverse
|
|
217
217
|
end
|
|
@@ -8,19 +8,15 @@ module AnalyticalBrain
|
|
|
8
8
|
class ActivateSkill < ::Tools::Base
|
|
9
9
|
def self.tool_name = "activate_skill"
|
|
10
10
|
|
|
11
|
-
def self.description = "
|
|
12
|
-
"The skill's content will be injected into the agent's system prompt."
|
|
11
|
+
def self.description = "Give the agent domain knowledge relevant to the current conversation."
|
|
13
12
|
|
|
14
13
|
def self.input_schema
|
|
15
14
|
{
|
|
16
15
|
type: "object",
|
|
17
16
|
properties: {
|
|
18
|
-
|
|
19
|
-
type: "string",
|
|
20
|
-
description: "Name of the skill to activate (from the available skills list)"
|
|
21
|
-
}
|
|
17
|
+
skill_name: {type: "string"}
|
|
22
18
|
},
|
|
23
|
-
required: %w[
|
|
19
|
+
required: %w[skill_name]
|
|
24
20
|
}
|
|
25
21
|
end
|
|
26
22
|
|
|
@@ -29,11 +25,11 @@ module AnalyticalBrain
|
|
|
29
25
|
@main_session = main_session
|
|
30
26
|
end
|
|
31
27
|
|
|
32
|
-
# @param input [Hash<String, Object>] with "
|
|
28
|
+
# @param input [Hash<String, Object>] with "skill_name" key
|
|
33
29
|
# @return [String] confirmation message with skill description
|
|
34
30
|
# @return [Hash] with :error key on validation failure
|
|
35
31
|
def execute(input)
|
|
36
|
-
skill_name = input["
|
|
32
|
+
skill_name = input["skill_name"].to_s.strip
|
|
37
33
|
return {error: "Skill name cannot be blank"} if skill_name.empty?
|
|
38
34
|
|
|
39
35
|
skill = @main_session.activate_skill(skill_name)
|
|
@@ -17,8 +17,7 @@ module AnalyticalBrain
|
|
|
17
17
|
|
|
18
18
|
def self.tool_name = "assign_nickname"
|
|
19
19
|
|
|
20
|
-
def self.description = "Assign a
|
|
21
|
-
"The nickname is permanent — it will not change."
|
|
20
|
+
def self.description = "Assign a permanent nickname to this sub-agent."
|
|
22
21
|
|
|
23
22
|
def self.input_schema
|
|
24
23
|
{
|
|
@@ -26,8 +25,7 @@ module AnalyticalBrain
|
|
|
26
25
|
properties: {
|
|
27
26
|
nickname: {
|
|
28
27
|
type: "string",
|
|
29
|
-
description: "
|
|
30
|
-
"Evocative of the task, fun, easy to type after @."
|
|
28
|
+
description: "Lowercase, hyphenated (e.g. 'loop-sleuth')."
|
|
31
29
|
}
|
|
32
30
|
},
|
|
33
31
|
required: %w[nickname]
|
|
@@ -7,19 +7,15 @@ module AnalyticalBrain
|
|
|
7
7
|
class DeactivateSkill < ::Tools::Base
|
|
8
8
|
def self.tool_name = "deactivate_skill"
|
|
9
9
|
|
|
10
|
-
def self.description = "
|
|
11
|
-
"The skill's content will be removed from the agent's system prompt."
|
|
10
|
+
def self.description = "Remove domain knowledge that is no longer relevant."
|
|
12
11
|
|
|
13
12
|
def self.input_schema
|
|
14
13
|
{
|
|
15
14
|
type: "object",
|
|
16
15
|
properties: {
|
|
17
|
-
|
|
18
|
-
type: "string",
|
|
19
|
-
description: "Name of the skill to deactivate (from the currently active skills list)"
|
|
20
|
-
}
|
|
16
|
+
skill_name: {type: "string"}
|
|
21
17
|
},
|
|
22
|
-
required: %w[
|
|
18
|
+
required: %w[skill_name]
|
|
23
19
|
}
|
|
24
20
|
end
|
|
25
21
|
|
|
@@ -28,11 +24,11 @@ module AnalyticalBrain
|
|
|
28
24
|
@main_session = main_session
|
|
29
25
|
end
|
|
30
26
|
|
|
31
|
-
# @param input [Hash<String, Object>] with "
|
|
27
|
+
# @param input [Hash<String, Object>] with "skill_name" key
|
|
32
28
|
# @return [String] confirmation message
|
|
33
29
|
# @return [Hash] with :error key on validation failure
|
|
34
30
|
def execute(input)
|
|
35
|
-
skill_name = input["
|
|
31
|
+
skill_name = input["skill_name"].to_s.strip
|
|
36
32
|
return {error: "Skill name cannot be blank"} if skill_name.empty?
|
|
37
33
|
|
|
38
34
|
@main_session.deactivate_skill(skill_name)
|
|
@@ -11,8 +11,7 @@ module AnalyticalBrain
|
|
|
11
11
|
class EverythingIsReady < ::Tools::Base
|
|
12
12
|
def self.tool_name = "everything_is_ready"
|
|
13
13
|
|
|
14
|
-
def self.description = "
|
|
15
|
-
"Call this when the session name and active skills are already appropriate."
|
|
14
|
+
def self.description = "Nothing else to do."
|
|
16
15
|
|
|
17
16
|
def self.input_schema
|
|
18
17
|
{type: "object", properties: {}, required: []}
|
|
@@ -7,17 +7,13 @@ module AnalyticalBrain
|
|
|
7
7
|
class FinishGoal < ::Tools::Base
|
|
8
8
|
def self.tool_name = "finish_goal"
|
|
9
9
|
|
|
10
|
-
def self.description = "Mark a goal as completed.
|
|
11
|
-
"Use this when the main agent has finished the work described by the goal."
|
|
10
|
+
def self.description = "Mark a goal as completed."
|
|
12
11
|
|
|
13
12
|
def self.input_schema
|
|
14
13
|
{
|
|
15
14
|
type: "object",
|
|
16
15
|
properties: {
|
|
17
|
-
goal_id: {
|
|
18
|
-
type: "integer",
|
|
19
|
-
description: "ID of the goal to mark as completed"
|
|
20
|
-
}
|
|
16
|
+
goal_id: {type: "integer"}
|
|
21
17
|
},
|
|
22
18
|
required: %w[goal_id]
|
|
23
19
|
}
|
|
@@ -49,7 +45,8 @@ module AnalyticalBrain
|
|
|
49
45
|
# brain learns to check status before retrying.
|
|
50
46
|
def complete(goal)
|
|
51
47
|
id = goal.id
|
|
52
|
-
|
|
48
|
+
desc = goal.description
|
|
49
|
+
return {error: "Goal already completed: #{desc} (id: #{id})"} if goal.completed?
|
|
53
50
|
|
|
54
51
|
released = 0
|
|
55
52
|
Goal.transaction do
|
|
@@ -58,7 +55,7 @@ module AnalyticalBrain
|
|
|
58
55
|
released = goal.release_orphaned_pins!
|
|
59
56
|
end
|
|
60
57
|
|
|
61
|
-
msg = "Goal completed: #{
|
|
58
|
+
msg = "Goal completed: #{desc} (id: #{id})"
|
|
62
59
|
msg += " (released #{released} orphaned pins)" if released > 0
|
|
63
60
|
msg
|
|
64
61
|
end
|