anima-core 1.1.3 → 1.3.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 +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- 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 +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- 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 +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- 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/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- 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 +38 -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/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- 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 +42 -5
- 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 +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- 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
|
data/config/environments/test.rb
CHANGED
|
@@ -6,4 +6,9 @@ Rails.application.configure do
|
|
|
6
6
|
config.consider_all_requests_local = true
|
|
7
7
|
config.active_support.deprecation = :stderr
|
|
8
8
|
config.active_job.queue_adapter = :test
|
|
9
|
+
|
|
10
|
+
# Fixed keys for test determinism — production/development load real keys from Rails credentials.
|
|
11
|
+
config.active_record.encryption.primary_key = "test-primary-key-for-ar-encryption"
|
|
12
|
+
config.active_record.encryption.deterministic_key = "test-deterministic-key-for-ar-encryption"
|
|
13
|
+
config.active_record.encryption.key_derivation_salt = "test-key-derivation-salt-for-ar-encryption"
|
|
9
14
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Nanosecond conversion for Time objects. Replaces raw
|
|
4
|
+
# Process.clock_gettime(CLOCK_REALTIME) calls so that
|
|
5
|
+
# ActiveSupport's freeze_time / travel_to work in tests.
|
|
6
|
+
class Time
|
|
7
|
+
# @return [Integer] nanoseconds since epoch
|
|
8
|
+
def to_ns
|
|
9
|
+
(to_r * 1_000_000_000).to_i
|
|
10
|
+
end
|
|
11
|
+
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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSecrets < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :secrets do |t|
|
|
6
|
+
t.string :namespace, null: false
|
|
7
|
+
t.string :key, null: false
|
|
8
|
+
t.text :value, null: false
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :secrets, [:namespace, :key], unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePendingMessages < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :pending_messages do |t|
|
|
6
|
+
t.references :session, null: false, foreign_key: true
|
|
7
|
+
t.text :content, null: false
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/agent_loop.rb
CHANGED
|
@@ -6,22 +6,17 @@
|
|
|
6
6
|
# Extracted from {TUI::Screens::Chat} so the same agent logic can run from
|
|
7
7
|
# the TUI, a background job, or an Action Cable channel.
|
|
8
8
|
#
|
|
9
|
-
# @note Not thread-safe. Callers must serialize concurrent
|
|
10
|
-
# (e.g.
|
|
9
|
+
# @note Not thread-safe. Callers must serialize concurrent access
|
|
10
|
+
# (e.g. {AgentRequestJob} uses session-level processing locks).
|
|
11
11
|
#
|
|
12
12
|
# @example Basic usage
|
|
13
13
|
# loop = AgentLoop.new(session: session)
|
|
14
|
-
# loop.
|
|
14
|
+
# loop.run
|
|
15
15
|
# loop.finalize
|
|
16
16
|
#
|
|
17
17
|
# @example With dependency injection (testing)
|
|
18
18
|
# loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
|
|
19
|
-
# loop.
|
|
20
|
-
#
|
|
21
|
-
# @example Background job usage (retry-safe)
|
|
22
|
-
# loop = AgentLoop.new(session: session)
|
|
23
|
-
# loop.run # processes persisted session messages without emitting UserMessage
|
|
24
|
-
# loop.finalize
|
|
19
|
+
# loop.run
|
|
25
20
|
class AgentLoop
|
|
26
21
|
# @return [Session] the conversation session this loop operates on
|
|
27
22
|
attr_reader :session
|
|
@@ -30,9 +25,9 @@ class AgentLoop
|
|
|
30
25
|
# @param shell_session [ShellSession, nil] injectable persistent shell;
|
|
31
26
|
# created automatically if not provided
|
|
32
27
|
# @param client [LLM::Client, nil] injectable LLM client;
|
|
33
|
-
# created lazily on first {#
|
|
28
|
+
# created lazily on first {#run} call if not provided
|
|
34
29
|
# @param registry [Tools::Registry, nil] injectable tool registry;
|
|
35
|
-
# built lazily on first {#
|
|
30
|
+
# built lazily on first {#run} call if not provided
|
|
36
31
|
def initialize(session:, shell_session: nil, client: nil, registry: nil)
|
|
37
32
|
@session = session
|
|
38
33
|
@shell_session = shell_session || ShellSession.new(session_id: session.id)
|
|
@@ -40,28 +35,6 @@ class AgentLoop
|
|
|
40
35
|
@registry = registry
|
|
41
36
|
end
|
|
42
37
|
|
|
43
|
-
# Runs the agent loop for a single user input.
|
|
44
|
-
#
|
|
45
|
-
# Persists the user event directly (the global Persister skips
|
|
46
|
-
# non-pending user messages because {AgentRequestJob} owns their
|
|
47
|
-
# lifecycle). Then emits a bus notification and delegates to {#run}.
|
|
48
|
-
# On error emits {Events::AgentMessage} with the error text.
|
|
49
|
-
#
|
|
50
|
-
# @param input [String] raw user input
|
|
51
|
-
# @return [String, nil] the agent's response text, or nil for blank input
|
|
52
|
-
def process(input)
|
|
53
|
-
text = input.to_s.strip
|
|
54
|
-
return if text.empty?
|
|
55
|
-
|
|
56
|
-
persist_user_event(text)
|
|
57
|
-
Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
|
|
58
|
-
run
|
|
59
|
-
rescue => error
|
|
60
|
-
error_message = "#{error.class}: #{error.message}"
|
|
61
|
-
Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
|
|
62
|
-
error_message
|
|
63
|
-
end
|
|
64
|
-
|
|
65
38
|
# Makes the first LLM API call to verify delivery. Called inside the
|
|
66
39
|
# Bounce Back transaction — if this raises, the user event rolls back.
|
|
67
40
|
#
|
|
@@ -127,7 +100,7 @@ class AgentLoop
|
|
|
127
100
|
|
|
128
101
|
# Tool classes available to all sessions by default.
|
|
129
102
|
# @return [Array<Class<Tools::Base>>]
|
|
130
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
|
|
103
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
|
|
131
104
|
|
|
132
105
|
# Tools that bypass {Session#granted_tools} filtering.
|
|
133
106
|
# The agent's reasoning depends on these regardless of task scope.
|
|
@@ -140,12 +113,9 @@ class AgentLoop
|
|
|
140
113
|
|
|
141
114
|
private
|
|
142
115
|
|
|
143
|
-
# @see Session#create_user_event
|
|
144
|
-
def persist_user_event(content)
|
|
145
|
-
@session.create_user_event(content)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
116
|
# Assembles LLM options (system prompt, environment context).
|
|
117
|
+
# Broadcasts the full debug context (system prompt + tool schemas)
|
|
118
|
+
# to debug-mode TUI clients on every LLM request.
|
|
149
119
|
# @return [Hash] options for {LLM::Client#chat_with_tools}
|
|
150
120
|
def build_llm_options
|
|
151
121
|
options = {}
|
|
@@ -154,6 +124,7 @@ class AgentLoop
|
|
|
154
124
|
end
|
|
155
125
|
prompt = @session.system_prompt(environment_context: env_context)
|
|
156
126
|
options[:system] = prompt if prompt
|
|
127
|
+
@session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
|
|
157
128
|
options
|
|
158
129
|
end
|
|
159
130
|
|
|
@@ -172,10 +143,12 @@ class AgentLoop
|
|
|
172
143
|
|
|
173
144
|
granted_standard_tools.each { |tool| registry.register(tool) }
|
|
174
145
|
|
|
175
|
-
|
|
146
|
+
if @session.sub_agent?
|
|
147
|
+
registry.register(Tools::MarkGoalCompleted)
|
|
148
|
+
else
|
|
176
149
|
registry.register(Tools::SpawnSubagent)
|
|
177
150
|
registry.register(Tools::SpawnSpecialist)
|
|
178
|
-
registry.register(Tools::
|
|
151
|
+
registry.register(Tools::OpenIssue)
|
|
179
152
|
end
|
|
180
153
|
|
|
181
154
|
register_mcp_tools(registry)
|
data/lib/agents/definition.rb
CHANGED
|
@@ -9,7 +9,9 @@ module AnalyticalBrain
|
|
|
9
9
|
# active depends on the session type:
|
|
10
10
|
#
|
|
11
11
|
# * **Parent sessions** — session naming, skill/workflow/goal management
|
|
12
|
-
# * **Child sessions** — sub-agent nickname assignment, skill
|
|
12
|
+
# * **Child sessions** — sub-agent nickname assignment, skill management
|
|
13
|
+
# (goal tracking and workflows disabled — sub-agents manage their sole goal
|
|
14
|
+
# via mark_goal_completed)
|
|
13
15
|
#
|
|
14
16
|
# Tools mutate the observed session directly (e.g. renaming it, activating
|
|
15
17
|
# skills), but no trace of the brain's reasoning is persisted — events are
|
|
@@ -27,7 +29,7 @@ module AnalyticalBrain
|
|
|
27
29
|
──────────────────────────────
|
|
28
30
|
SESSION NAMING
|
|
29
31
|
──────────────────────────────
|
|
30
|
-
|
|
32
|
+
Name the session when the topic becomes clear. Rename if it shifts.
|
|
31
33
|
Format: one emoji + 1-3 descriptive words.
|
|
32
34
|
PROMPT
|
|
33
35
|
tools: [Tools::RenameSession]
|
|
@@ -38,10 +40,10 @@ module AnalyticalBrain
|
|
|
38
40
|
──────────────────────────────
|
|
39
41
|
SUB-AGENT NAMING
|
|
40
42
|
──────────────────────────────
|
|
41
|
-
|
|
43
|
+
Give this sub-agent a memorable nickname based on its task.
|
|
42
44
|
Format: 1-3 lowercase words joined by hyphens (e.g. "loop-sleuth", "api-scout").
|
|
43
|
-
Evocative
|
|
44
|
-
|
|
45
|
+
Evocative, fun, easy to type after @.
|
|
46
|
+
One nickname per call. If taken, pick another — no numeric suffixes.
|
|
45
47
|
PROMPT
|
|
46
48
|
tools: [Tools::AssignNickname]
|
|
47
49
|
),
|
|
@@ -51,8 +53,9 @@ module AnalyticalBrain
|
|
|
51
53
|
──────────────────────────────
|
|
52
54
|
SKILL MANAGEMENT
|
|
53
55
|
──────────────────────────────
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
Activate skills when the conversation signals intent — before the agent acts on it.
|
|
57
|
+
Late activation means the agent works without domain knowledge.
|
|
58
|
+
Deactivate when the agent moves to a different domain.
|
|
56
59
|
Multiple skills can be active at once.
|
|
57
60
|
PROMPT
|
|
58
61
|
tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
|
|
@@ -63,11 +66,11 @@ module AnalyticalBrain
|
|
|
63
66
|
──────────────────────────────
|
|
64
67
|
WORKFLOW MANAGEMENT
|
|
65
68
|
──────────────────────────────
|
|
66
|
-
|
|
67
|
-
Read the returned content and use judgment to create
|
|
69
|
+
Activate a workflow when the user starts a multi-step task that matches one.
|
|
70
|
+
Read the returned content and use judgment to create goals — not a mechanical 1:1 mapping.
|
|
68
71
|
Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
|
|
69
|
-
|
|
70
|
-
Only one workflow
|
|
72
|
+
Deactivate the workflow when it completes or the user shifts focus.
|
|
73
|
+
Only one workflow active at a time — activating a new one replaces the previous.
|
|
71
74
|
PROMPT
|
|
72
75
|
tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
|
|
73
76
|
),
|
|
@@ -77,11 +80,11 @@ module AnalyticalBrain
|
|
|
77
80
|
──────────────────────────────
|
|
78
81
|
GOAL TRACKING
|
|
79
82
|
──────────────────────────────
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
Create a root goal when the user starts a multi-step task.
|
|
84
|
+
Break it into sub-goals as the plan becomes clear.
|
|
85
|
+
Refine goal wording as understanding evolves.
|
|
86
|
+
Mark goals complete when the agent finishes the work they describe.
|
|
87
|
+
Completing a root goal cascades — all sub-goals are finished too.
|
|
85
88
|
Never duplicate an existing goal — check the active goals list first.
|
|
86
89
|
PROMPT
|
|
87
90
|
tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
|
|
@@ -89,22 +92,22 @@ module AnalyticalBrain
|
|
|
89
92
|
}.freeze
|
|
90
93
|
|
|
91
94
|
BASE_PROMPT = <<~PROMPT
|
|
92
|
-
You
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
You manage context for the main agent — skills, goals, workflows, and session names.
|
|
96
|
+
Watch the conversation and act when context needs updating.
|
|
97
|
+
Communicate only through tool calls — never output text.
|
|
95
98
|
PROMPT
|
|
96
99
|
|
|
97
100
|
COMPLETION_PROMPT = <<~PROMPT
|
|
98
101
|
──────────────────────────────
|
|
99
102
|
COMPLETION
|
|
100
103
|
──────────────────────────────
|
|
101
|
-
|
|
102
|
-
If nothing needs
|
|
104
|
+
Always finish with everything_is_ready.
|
|
105
|
+
If nothing needs attention, call it immediately.
|
|
103
106
|
PROMPT
|
|
104
107
|
|
|
105
108
|
# Which responsibilities activate for each session type.
|
|
106
109
|
PARENT_RESPONSIBILITIES = %i[session_naming skill_management workflow_management goal_tracking].freeze
|
|
107
|
-
CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management
|
|
110
|
+
CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management].freeze
|
|
108
111
|
|
|
109
112
|
# @param session [Session] the session to observe and maintain
|
|
110
113
|
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
@@ -118,7 +121,7 @@ module AnalyticalBrain
|
|
|
118
121
|
end
|
|
119
122
|
|
|
120
123
|
# Runs the analytical brain loop. Builds context from the session's
|
|
121
|
-
# recent
|
|
124
|
+
# recent messages, calls the LLM with the session-appropriate tool set,
|
|
122
125
|
# and executes any tool calls against the session.
|
|
123
126
|
#
|
|
124
127
|
# Events emitted during tool execution are not persisted — the phantom
|
|
@@ -130,12 +133,12 @@ module AnalyticalBrain
|
|
|
130
133
|
messages = build_messages
|
|
131
134
|
sid = @session.id
|
|
132
135
|
if messages.empty?
|
|
133
|
-
log.debug("session=#{sid} — no
|
|
136
|
+
log.debug("session=#{sid} — no messages, skipping")
|
|
134
137
|
return
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
system = build_system_prompt
|
|
138
|
-
log.info("session=#{sid} — running (#{
|
|
141
|
+
log.info("session=#{sid} — running (#{recent_messages.size} messages)")
|
|
139
142
|
log.debug("system prompt:\n#{system}")
|
|
140
143
|
log.debug("user message:\n#{messages.first[:content]}")
|
|
141
144
|
|
|
@@ -162,18 +165,18 @@ module AnalyticalBrain
|
|
|
162
165
|
active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
|
|
163
166
|
end
|
|
164
167
|
|
|
165
|
-
# Builds a condensed transcript of recent
|
|
168
|
+
# Builds a condensed transcript of recent messages as a single user message.
|
|
166
169
|
# The framing differs by session type:
|
|
167
170
|
#
|
|
168
171
|
# * **Parent:** "The main session is working on this: [transcript]"
|
|
169
172
|
# * **Child:** "A sub-agent has been spawned with this task: [transcript]"
|
|
170
173
|
#
|
|
171
|
-
# @return [Array<Hash>] single-element messages array, or empty if no
|
|
174
|
+
# @return [Array<Hash>] single-element messages array, or empty if no messages
|
|
172
175
|
def build_messages
|
|
173
|
-
|
|
174
|
-
return [] if
|
|
176
|
+
messages = recent_messages
|
|
177
|
+
return [] if messages.empty?
|
|
175
178
|
|
|
176
|
-
transcript =
|
|
179
|
+
transcript = messages.filter_map { |msg| MessageDecorator.for(msg)&.render("brain") }.join("\n")
|
|
177
180
|
|
|
178
181
|
if @session.sub_agent?
|
|
179
182
|
build_child_message(transcript)
|
|
@@ -189,7 +192,7 @@ module AnalyticalBrain
|
|
|
189
192
|
#{transcript}
|
|
190
193
|
```
|
|
191
194
|
|
|
192
|
-
|
|
195
|
+
Review and take any needed actions, then call everything_is_ready.
|
|
193
196
|
MSG
|
|
194
197
|
[{role: "user", content: content}]
|
|
195
198
|
end
|
|
@@ -201,17 +204,17 @@ module AnalyticalBrain
|
|
|
201
204
|
#{transcript}
|
|
202
205
|
```
|
|
203
206
|
|
|
204
|
-
Assign a
|
|
207
|
+
Assign a nickname and activate relevant skills, then call everything_is_ready.
|
|
205
208
|
MSG
|
|
206
209
|
[{role: "user", content: content}]
|
|
207
210
|
end
|
|
208
211
|
|
|
209
|
-
# @return [Array<
|
|
210
|
-
def
|
|
211
|
-
@session.
|
|
212
|
-
.
|
|
212
|
+
# @return [Array<Message>] most recent messages in chronological order
|
|
213
|
+
def recent_messages
|
|
214
|
+
@session.messages
|
|
215
|
+
.context_messages
|
|
213
216
|
.reorder(id: :desc)
|
|
214
|
-
.limit(Anima::Settings.
|
|
217
|
+
.limit(Anima::Settings.analytical_brain_message_window)
|
|
215
218
|
.to_a
|
|
216
219
|
.reverse
|
|
217
220
|
end
|