anima-core 1.2.0 → 1.4.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 +14 -8
- data/README.md +96 -23
- 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 +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -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/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- 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 +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/db/structure.sql
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "goals" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "parent_goal_id" integer, "description" text NOT NULL, "status" varchar DEFAULT 'active' NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "completed_at" datetime(6), "evicted_at" datetime(6), CONSTRAINT "fk_rails_874b7534ae"
|
|
4
|
+
FOREIGN KEY ("session_id")
|
|
5
|
+
REFERENCES "sessions" ("id")
|
|
6
|
+
, CONSTRAINT "fk_rails_feeb9df31e"
|
|
7
|
+
FOREIGN KEY ("parent_goal_id")
|
|
8
|
+
REFERENCES "goals" ("id")
|
|
9
|
+
);
|
|
10
|
+
CREATE INDEX "index_goals_on_session_id" ON "goals" ("session_id");
|
|
11
|
+
CREATE INDEX "index_goals_on_parent_goal_id" ON "goals" ("parent_goal_id");
|
|
12
|
+
CREATE INDEX "index_goals_on_session_id_and_status" ON "goals" ("session_id", "status");
|
|
13
|
+
CREATE TABLE IF NOT EXISTS "messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "message_type" varchar NOT NULL, "payload" json DEFAULT '{}' NOT NULL, "timestamp" integer(8) NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "token_count" integer DEFAULT 0 NOT NULL, "tool_use_id" varchar, "status" varchar, "api_metrics" json, CONSTRAINT "fk_rails_1ee2a92df0"
|
|
14
|
+
FOREIGN KEY ("session_id")
|
|
15
|
+
REFERENCES "sessions" ("id")
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX "index_messages_on_session_id_and_status" ON "messages" ("session_id", "status");
|
|
18
|
+
CREATE INDEX "index_messages_on_tool_use_id" ON "messages" ("tool_use_id");
|
|
19
|
+
CREATE INDEX "index_messages_on_session_id" ON "messages" ("session_id");
|
|
20
|
+
CREATE INDEX "index_messages_on_message_type" ON "messages" ("message_type");
|
|
21
|
+
CREATE INDEX "index_messages_on_session_id_and_message_type" ON "messages" ("session_id", "message_type");
|
|
22
|
+
CREATE TABLE IF NOT EXISTS "pinned_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message_id" integer NOT NULL, "display_text" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_4a5f237c43"
|
|
23
|
+
FOREIGN KEY ("message_id")
|
|
24
|
+
REFERENCES "messages" ("id")
|
|
25
|
+
);
|
|
26
|
+
CREATE UNIQUE INDEX "index_pinned_messages_on_message_id" ON "pinned_messages" ("message_id");
|
|
27
|
+
CREATE TABLE IF NOT EXISTS "goal_pinned_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "goal_id" integer NOT NULL, "pinned_message_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_fb51bfeebe"
|
|
28
|
+
FOREIGN KEY ("pinned_message_id")
|
|
29
|
+
REFERENCES "pinned_messages" ("id")
|
|
30
|
+
, CONSTRAINT "fk_rails_689fd4bf8a"
|
|
31
|
+
FOREIGN KEY ("goal_id")
|
|
32
|
+
REFERENCES "goals" ("id")
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX "index_goal_pinned_messages_on_goal_id" ON "goal_pinned_messages" ("goal_id");
|
|
35
|
+
CREATE INDEX "index_goal_pinned_messages_on_pinned_message_id" ON "goal_pinned_messages" ("pinned_message_id");
|
|
36
|
+
CREATE UNIQUE INDEX "index_goal_pinned_messages_on_goal_id_and_pinned_message_id" ON "goal_pinned_messages" ("goal_id", "pinned_message_id");
|
|
37
|
+
CREATE TABLE IF NOT EXISTS "snapshots" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "text" text NOT NULL, "from_message_id" integer NOT NULL, "to_message_id" integer NOT NULL, "level" integer DEFAULT 1 NOT NULL, "token_count" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_eb2ad51db9"
|
|
38
|
+
FOREIGN KEY ("session_id")
|
|
39
|
+
REFERENCES "sessions" ("id")
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX "index_snapshots_on_session_id" ON "snapshots" ("session_id");
|
|
42
|
+
CREATE INDEX "index_snapshots_on_session_id_and_level" ON "snapshots" ("session_id", "level");
|
|
43
|
+
CREATE INDEX "index_snapshots_on_session_and_event_range" ON "snapshots" ("session_id", "from_message_id", "to_message_id");
|
|
44
|
+
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
45
|
+
searchable_text,
|
|
46
|
+
content='',
|
|
47
|
+
contentless_delete=1,
|
|
48
|
+
tokenize='porter unicode61'
|
|
49
|
+
)
|
|
50
|
+
/* messages_fts(searchable_text) */;
|
|
51
|
+
CREATE TABLE IF NOT EXISTS 'messages_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS 'messages_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
|
53
|
+
CREATE TABLE IF NOT EXISTS 'messages_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB, origin INTEGER);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS 'messages_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
|
55
|
+
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages
|
|
56
|
+
WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
57
|
+
OR (NEW.message_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
|
|
58
|
+
BEGIN
|
|
59
|
+
INSERT INTO messages_fts(rowid, searchable_text)
|
|
60
|
+
VALUES (
|
|
61
|
+
NEW.id,
|
|
62
|
+
CASE
|
|
63
|
+
WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
64
|
+
THEN json_extract(NEW.payload, '$.content')
|
|
65
|
+
WHEN NEW.message_type = 'tool_call'
|
|
66
|
+
THEN json_extract(NEW.payload, '$.tool_input.thoughts')
|
|
67
|
+
END
|
|
68
|
+
);
|
|
69
|
+
END;
|
|
70
|
+
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages
|
|
71
|
+
WHEN OLD.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
72
|
+
OR (OLD.message_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
|
|
73
|
+
BEGIN
|
|
74
|
+
DELETE FROM messages_fts WHERE rowid = OLD.id;
|
|
75
|
+
END;
|
|
76
|
+
CREATE TABLE IF NOT EXISTS "secrets" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "namespace" varchar NOT NULL, "key" varchar NOT NULL, "value" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
|
77
|
+
CREATE UNIQUE INDEX "index_secrets_on_namespace_and_key" ON "secrets" ("namespace", "key");
|
|
78
|
+
CREATE INDEX "index_goals_on_evicted_at" ON "goals" ("evicted_at");
|
|
79
|
+
CREATE TABLE IF NOT EXISTS "pending_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "content" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "source_type" varchar DEFAULT 'user' NOT NULL, "source_name" varchar, CONSTRAINT "fk_rails_007242365b"
|
|
80
|
+
FOREIGN KEY ("session_id")
|
|
81
|
+
REFERENCES "sessions" ("id")
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX "index_pending_messages_on_session_id" ON "pending_messages" ("session_id");
|
|
84
|
+
CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "view_mode" varchar DEFAULT 'basic' NOT NULL, "processing" boolean DEFAULT FALSE NOT NULL, "parent_session_id" integer, "prompt" text, "granted_tools" text, "name" varchar, "viewport_message_ids" json DEFAULT '[]' NOT NULL, "active_skills" json DEFAULT '[]' NOT NULL, "active_workflow" varchar, "interrupt_requested" boolean DEFAULT FALSE NOT NULL, "mneme_boundary_message_id" integer, "mneme_snapshot_first_message_id" integer, "mneme_snapshot_last_message_id" integer, "initial_cwd" varchar, CONSTRAINT "fk_rails_045409ac27"
|
|
85
|
+
FOREIGN KEY ("parent_session_id")
|
|
86
|
+
REFERENCES "sessions" ("id")
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX "index_sessions_on_parent_session_id" ON "sessions" ("parent_session_id");
|
|
89
|
+
INSERT INTO "schema_migrations" (version) VALUES
|
|
90
|
+
('20260403080031'),
|
|
91
|
+
('20260401210935'),
|
|
92
|
+
('20260401180000'),
|
|
93
|
+
('20260330120000'),
|
|
94
|
+
('20260329120000'),
|
|
95
|
+
('20260328152142'),
|
|
96
|
+
('20260328100000'),
|
|
97
|
+
('20260326180000'),
|
|
98
|
+
('20260321140100'),
|
|
99
|
+
('20260321140000'),
|
|
100
|
+
('20260321120000'),
|
|
101
|
+
('20260321080000'),
|
|
102
|
+
('20260316094817'),
|
|
103
|
+
('20260315191105'),
|
|
104
|
+
('20260315144837'),
|
|
105
|
+
('20260315140843'),
|
|
106
|
+
('20260315100000'),
|
|
107
|
+
('20260314150000'),
|
|
108
|
+
('20260314140000'),
|
|
109
|
+
('20260314112417'),
|
|
110
|
+
('20260314075248'),
|
|
111
|
+
('20260313020000'),
|
|
112
|
+
('20260313010000'),
|
|
113
|
+
('20260312170000'),
|
|
114
|
+
('20260308160000'),
|
|
115
|
+
('20260308150000'),
|
|
116
|
+
('20260308140000'),
|
|
117
|
+
('20260308130000'),
|
|
118
|
+
('20260308124203'),
|
|
119
|
+
('20260308124202');
|
|
120
|
+
|
data/lib/agent_loop.rb
CHANGED
|
@@ -1,27 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
# Orchestrates the LLM agent loop: accepts user input, runs the tool-use
|
|
4
6
|
# cycle via {LLM::Client}, and emits events through {Events::Bus}.
|
|
5
7
|
#
|
|
6
8
|
# Extracted from {TUI::Screens::Chat} so the same agent logic can run from
|
|
7
9
|
# the TUI, a background job, or an Action Cable channel.
|
|
8
10
|
#
|
|
9
|
-
# @note Not thread-safe. Callers must serialize concurrent
|
|
10
|
-
# (e.g.
|
|
11
|
+
# @note Not thread-safe. Callers must serialize concurrent access
|
|
12
|
+
# (e.g. {AgentRequestJob} uses session-level processing locks).
|
|
11
13
|
#
|
|
12
14
|
# @example Basic usage
|
|
13
15
|
# loop = AgentLoop.new(session: session)
|
|
14
|
-
# loop.
|
|
16
|
+
# loop.run
|
|
15
17
|
# loop.finalize
|
|
16
18
|
#
|
|
17
19
|
# @example With dependency injection (testing)
|
|
18
20
|
# 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
|
|
21
|
+
# loop.run
|
|
25
22
|
class AgentLoop
|
|
26
23
|
# @return [Session] the conversation session this loop operates on
|
|
27
24
|
attr_reader :session
|
|
@@ -30,38 +27,17 @@ class AgentLoop
|
|
|
30
27
|
# @param shell_session [ShellSession, nil] injectable persistent shell;
|
|
31
28
|
# created automatically if not provided
|
|
32
29
|
# @param client [LLM::Client, nil] injectable LLM client;
|
|
33
|
-
# created lazily on first {#
|
|
30
|
+
# created lazily on first {#run} call if not provided
|
|
34
31
|
# @param registry [Tools::Registry, nil] injectable tool registry;
|
|
35
|
-
# built lazily on first {#
|
|
32
|
+
# built lazily on first {#run} call if not provided
|
|
36
33
|
def initialize(session:, shell_session: nil, client: nil, registry: nil)
|
|
37
34
|
@session = session
|
|
38
35
|
@shell_session = shell_session || ShellSession.new(session_id: session.id)
|
|
36
|
+
restore_initial_cwd
|
|
39
37
|
@client = client
|
|
40
38
|
@registry = registry
|
|
41
39
|
end
|
|
42
40
|
|
|
43
|
-
# Runs the agent loop for a single user input.
|
|
44
|
-
#
|
|
45
|
-
# Persists the user message 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_message(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
41
|
# Makes the first LLM API call to verify delivery. Called inside the
|
|
66
42
|
# Bounce Back transaction — if this raises, the user event rolls back.
|
|
67
43
|
#
|
|
@@ -71,7 +47,7 @@ class AgentLoop
|
|
|
71
47
|
# @return [void]
|
|
72
48
|
# @raise [Providers::Anthropic::Error] on any LLM delivery failure
|
|
73
49
|
def deliver!
|
|
74
|
-
@client ||=
|
|
50
|
+
@client ||= build_client
|
|
75
51
|
@registry ||= build_tool_registry
|
|
76
52
|
|
|
77
53
|
messages = @session.messages_for_llm
|
|
@@ -82,6 +58,7 @@ class AgentLoop
|
|
|
82
58
|
messages: messages,
|
|
83
59
|
max_tokens: @client.max_tokens,
|
|
84
60
|
tools: @registry.schemas,
|
|
61
|
+
include_metrics: true,
|
|
85
62
|
**options
|
|
86
63
|
)
|
|
87
64
|
end
|
|
@@ -100,7 +77,7 @@ class AgentLoop
|
|
|
100
77
|
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
101
78
|
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
102
79
|
def run
|
|
103
|
-
@client ||=
|
|
80
|
+
@client ||= build_client
|
|
104
81
|
@registry ||= build_tool_registry
|
|
105
82
|
|
|
106
83
|
messages = @session.messages_for_llm
|
|
@@ -109,14 +86,20 @@ class AgentLoop
|
|
|
109
86
|
first_resp = @first_response
|
|
110
87
|
@first_response = nil
|
|
111
88
|
|
|
112
|
-
|
|
89
|
+
between_rounds = -> { @session.promote_pending_messages! }
|
|
90
|
+
|
|
91
|
+
result = @client.chat_with_tools(
|
|
113
92
|
messages, registry: @registry, session_id: @session.id,
|
|
114
|
-
first_response: first_resp, **options
|
|
93
|
+
first_response: first_resp, between_rounds: between_rounds, **options
|
|
115
94
|
)
|
|
116
|
-
return unless
|
|
117
|
-
|
|
118
|
-
Events::Bus.emit(Events::AgentMessage.new(
|
|
119
|
-
|
|
95
|
+
return unless result
|
|
96
|
+
|
|
97
|
+
Events::Bus.emit(Events::AgentMessage.new(
|
|
98
|
+
content: result[:text],
|
|
99
|
+
session_id: @session.id,
|
|
100
|
+
api_metrics: result[:api_metrics]
|
|
101
|
+
))
|
|
102
|
+
result[:text]
|
|
120
103
|
end
|
|
121
104
|
|
|
122
105
|
# Clean up the underlying {ShellSession} PTY and resources.
|
|
@@ -127,7 +110,7 @@ class AgentLoop
|
|
|
127
110
|
|
|
128
111
|
# Tool classes available to all sessions by default.
|
|
129
112
|
# @return [Array<Class<Tools::Base>>]
|
|
130
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
|
|
113
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
|
|
131
114
|
|
|
132
115
|
# Tools that bypass {Session#granted_tools} filtering.
|
|
133
116
|
# The agent's reasoning depends on these regardless of task scope.
|
|
@@ -140,20 +123,37 @@ class AgentLoop
|
|
|
140
123
|
|
|
141
124
|
private
|
|
142
125
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
126
|
+
# Restores the working directory inherited from the parent session.
|
|
127
|
+
# Sub-agents store the parent's CWD at spawn time so their shell starts
|
|
128
|
+
# in the same directory the parent was working in.
|
|
129
|
+
# @return [void]
|
|
130
|
+
def restore_initial_cwd
|
|
131
|
+
cwd = @session.initial_cwd
|
|
132
|
+
return unless cwd.present? && File.directory?(cwd)
|
|
133
|
+
|
|
134
|
+
@shell_session.run("cd #{Shellwords.shellescape(cwd)}")
|
|
146
135
|
end
|
|
147
136
|
|
|
148
|
-
#
|
|
137
|
+
# Builds the LLM client with the appropriate model for this session type.
|
|
138
|
+
# Sub-agents use a separate (typically cheaper) model from Settings.
|
|
139
|
+
# @return [LLM::Client]
|
|
140
|
+
def build_client
|
|
141
|
+
if @session.sub_agent?
|
|
142
|
+
LLM::Client.new(model: Anima::Settings.subagent_model)
|
|
143
|
+
else
|
|
144
|
+
LLM::Client.new
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Assembles LLM options (system prompt).
|
|
149
|
+
# Broadcasts the full debug context (system prompt + tool schemas)
|
|
150
|
+
# to debug-mode TUI clients on every LLM request.
|
|
149
151
|
# @return [Hash] options for {LLM::Client#chat_with_tools}
|
|
150
152
|
def build_llm_options
|
|
151
153
|
options = {}
|
|
152
|
-
|
|
153
|
-
env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
|
|
154
|
-
end
|
|
155
|
-
prompt = @session.system_prompt(environment_context: env_context)
|
|
154
|
+
prompt = @session.system_prompt
|
|
156
155
|
options[:system] = prompt if prompt
|
|
156
|
+
@session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
|
|
157
157
|
options
|
|
158
158
|
end
|
|
159
159
|
|
|
@@ -172,7 +172,9 @@ class AgentLoop
|
|
|
172
172
|
|
|
173
173
|
granted_standard_tools.each { |tool| registry.register(tool) }
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
if @session.sub_agent?
|
|
176
|
+
registry.register(Tools::MarkGoalCompleted)
|
|
177
|
+
else
|
|
176
178
|
registry.register(Tools::SpawnSubagent)
|
|
177
179
|
registry.register(Tools::SpawnSpecialist)
|
|
178
180
|
registry.register(Tools::OpenIssue)
|
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
|
|
@@ -51,7 +53,8 @@ module AnalyticalBrain
|
|
|
51
53
|
──────────────────────────────
|
|
52
54
|
SKILL MANAGEMENT
|
|
53
55
|
──────────────────────────────
|
|
54
|
-
Activate skills when the conversation
|
|
56
|
+
Activate skills when the conversation signals intent — before the agent acts on it.
|
|
57
|
+
Late activation means the agent works without domain knowledge.
|
|
55
58
|
Deactivate when the agent moves to a different domain.
|
|
56
59
|
Multiple skills can be active at once.
|
|
57
60
|
PROMPT
|
|
@@ -104,7 +107,7 @@ module AnalyticalBrain
|
|
|
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)
|
|
@@ -201,7 +204,7 @@ module AnalyticalBrain
|
|
|
201
204
|
#{transcript}
|
|
202
205
|
```
|
|
203
206
|
|
|
204
|
-
Assign a nickname
|
|
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
|
|
@@ -269,9 +272,15 @@ module AnalyticalBrain
|
|
|
269
272
|
SECTION
|
|
270
273
|
end
|
|
271
274
|
|
|
275
|
+
# Skills already visible in the viewport are excluded from the catalog
|
|
276
|
+
# so the brain doesn't re-activate them. When a skill evicts from the
|
|
277
|
+
# viewport, it reappears here and the brain can re-inject if relevant.
|
|
278
|
+
#
|
|
279
|
+
# @see Session#skills_in_viewport
|
|
272
280
|
# @return [String] available skills list for the analytical brain
|
|
273
281
|
def skills_catalog_section
|
|
274
|
-
|
|
282
|
+
present = @session.skills_in_viewport
|
|
283
|
+
catalog = Skills::Registry.instance.catalog.except(*present)
|
|
275
284
|
items = if catalog.empty?
|
|
276
285
|
"None"
|
|
277
286
|
else
|
|
@@ -285,9 +294,13 @@ module AnalyticalBrain
|
|
|
285
294
|
SECTION
|
|
286
295
|
end
|
|
287
296
|
|
|
297
|
+
# Workflows already visible in the viewport are excluded from the catalog.
|
|
298
|
+
#
|
|
299
|
+
# @see Session#workflow_in_viewport
|
|
288
300
|
# @return [String] available workflows list for the analytical brain
|
|
289
301
|
def workflows_catalog_section
|
|
290
|
-
|
|
302
|
+
present = @session.workflow_in_viewport
|
|
303
|
+
catalog = Workflows::Registry.instance.catalog.reject { |name, _| name == present }
|
|
291
304
|
items = if catalog.empty?
|
|
292
305
|
"None"
|
|
293
306
|
else
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module AnalyticalBrain
|
|
4
4
|
module Tools
|
|
5
5
|
# Activates a domain knowledge skill on the main session.
|
|
6
|
-
# The skill's content
|
|
7
|
-
#
|
|
6
|
+
# The skill's content enters the conversation as a phantom
|
|
7
|
+
# tool_use/tool_result pair through the {PendingMessage} promotion flow.
|
|
8
8
|
class ActivateSkill < ::Tools::Base
|
|
9
9
|
def self.tool_name = "activate_skill"
|
|
10
10
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module AnalyticalBrain
|
|
4
4
|
module Tools
|
|
5
5
|
# Deactivates a domain knowledge skill on the main session.
|
|
6
|
-
# The skill's
|
|
6
|
+
# The skill's recalled message stays in the conversation and
|
|
7
|
+
# evicts naturally from the sliding window.
|
|
7
8
|
class DeactivateSkill < ::Tools::Base
|
|
8
9
|
def self.tool_name = "deactivate_skill"
|
|
9
10
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module AnalyticalBrain
|
|
4
4
|
module Tools
|
|
5
5
|
# Deactivates the current workflow on the main session.
|
|
6
|
-
# The workflow's
|
|
6
|
+
# The workflow's recalled message stays in the conversation and
|
|
7
|
+
# evicts naturally from the sliding window.
|
|
7
8
|
class DeactivateWorkflow < ::Tools::Base
|
|
8
9
|
def self.tool_name = "deactivate_workflow"
|
|
9
10
|
|
|
@@ -5,6 +5,8 @@ module AnalyticalBrain
|
|
|
5
5
|
# Marks a goal as completed on the main session. Sets the status to
|
|
6
6
|
# "completed" and records the completion timestamp.
|
|
7
7
|
class FinishGoal < ::Tools::Base
|
|
8
|
+
include GoalMessaging
|
|
9
|
+
|
|
8
10
|
def self.tool_name = "finish_goal"
|
|
9
11
|
|
|
10
12
|
def self.description = "Mark a goal as completed."
|
|
@@ -57,6 +59,7 @@ module AnalyticalBrain
|
|
|
57
59
|
|
|
58
60
|
msg = "Goal completed: #{desc} (id: #{id})"
|
|
59
61
|
msg += " (released #{released} orphaned pins)" if released > 0
|
|
62
|
+
enqueue_goal_message(goal, msg)
|
|
60
63
|
msg
|
|
61
64
|
end
|
|
62
65
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticalBrain
|
|
4
|
+
module Tools
|
|
5
|
+
# Shared helper for goal tools that enqueue phantom pair messages
|
|
6
|
+
# when the analytical brain creates, updates, or completes a goal.
|
|
7
|
+
#
|
|
8
|
+
# Including classes must set +@main_session+ to the owning {Session}.
|
|
9
|
+
module GoalMessaging
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Enqueues a goal event as a {PendingMessage} on the main session.
|
|
13
|
+
# Promoted to a phantom tool_use/tool_result pair so the main agent
|
|
14
|
+
# sees "I recalled this goal event" in its conversation history.
|
|
15
|
+
#
|
|
16
|
+
# @param goal [Goal] the goal that changed
|
|
17
|
+
# @param confirmation [String] human-readable event description
|
|
18
|
+
# @return [PendingMessage]
|
|
19
|
+
def enqueue_goal_message(goal, confirmation)
|
|
20
|
+
@main_session.pending_messages.create!(
|
|
21
|
+
content: confirmation,
|
|
22
|
+
source_type: "goal",
|
|
23
|
+
source_name: goal.id.to_s
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,8 +4,8 @@ module AnalyticalBrain
|
|
|
4
4
|
module Tools
|
|
5
5
|
# Reads and activates a workflow on the main session.
|
|
6
6
|
# Returns the full workflow content so the brain can create goals from it.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# The workflow's content enters the conversation as a phantom
|
|
8
|
+
# tool_use/tool_result pair through the {PendingMessage} promotion flow.
|
|
9
9
|
class ReadWorkflow < ::Tools::Base
|
|
10
10
|
def self.tool_name = "read_workflow"
|
|
11
11
|
|
|
@@ -6,6 +6,8 @@ module AnalyticalBrain
|
|
|
6
6
|
# objectives (semantic episodes); sub-goals are TODO-style steps within
|
|
7
7
|
# a root goal. The two-level hierarchy is enforced by the Goal model.
|
|
8
8
|
class SetGoal < ::Tools::Base
|
|
9
|
+
include GoalMessaging
|
|
10
|
+
|
|
9
11
|
def self.tool_name = "set_goal"
|
|
10
12
|
|
|
11
13
|
def self.description = "Create a goal or sub-goal."
|
|
@@ -40,7 +42,9 @@ module AnalyticalBrain
|
|
|
40
42
|
description: description,
|
|
41
43
|
parent_goal_id: input["parent_goal_id"]
|
|
42
44
|
)
|
|
43
|
-
format_confirmation(goal)
|
|
45
|
+
confirmation = format_confirmation(goal)
|
|
46
|
+
enqueue_goal_message(goal, confirmation)
|
|
47
|
+
confirmation
|
|
44
48
|
rescue ActiveRecord::RecordInvalid => error
|
|
45
49
|
{error: error.record.errors.full_messages.join(", ")}
|
|
46
50
|
end
|
|
@@ -13,6 +13,8 @@ module AnalyticalBrain
|
|
|
13
13
|
# Completed goals cannot be updated; attempting to do so returns an error
|
|
14
14
|
# so the brain learns to check status before calling this tool.
|
|
15
15
|
class UpdateGoal < ::Tools::Base
|
|
16
|
+
include GoalMessaging
|
|
17
|
+
|
|
16
18
|
def self.tool_name = "update_goal"
|
|
17
19
|
|
|
18
20
|
def self.description = "Refine a goal's wording as understanding evolves."
|
|
@@ -49,7 +51,9 @@ module AnalyticalBrain
|
|
|
49
51
|
return {error: "Cannot update completed goal: #{goal.description} (id: #{goal_id})"} if goal.completed?
|
|
50
52
|
|
|
51
53
|
goal.update!(description: description)
|
|
52
|
-
"Goal updated: #{description} (id: #{goal_id})"
|
|
54
|
+
confirmation = "Goal updated: #{description} (id: #{goal_id})"
|
|
55
|
+
enqueue_goal_message(goal, confirmation)
|
|
56
|
+
confirmation
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
end
|
|
@@ -5,8 +5,8 @@ require "thor"
|
|
|
5
5
|
module Anima
|
|
6
6
|
class CLI < Thor
|
|
7
7
|
class Mcp < Thor
|
|
8
|
-
# CLI commands for managing MCP secrets stored in
|
|
9
|
-
#
|
|
8
|
+
# CLI commands for managing MCP secrets stored in the encrypted
|
|
9
|
+
# secrets table. Secrets are referenced in mcp.toml via
|
|
10
10
|
# +${credential:key_name}+ syntax.
|
|
11
11
|
#
|
|
12
12
|
# @example Store a secret
|
|
@@ -22,7 +22,7 @@ module Anima
|
|
|
22
22
|
true
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
desc "set KEY=VALUE", "Store an MCP secret in encrypted
|
|
25
|
+
desc "set KEY=VALUE", "Store an MCP secret in encrypted secrets"
|
|
26
26
|
def set(pair)
|
|
27
27
|
key, value = pair.split("=", 2)
|
|
28
28
|
unless value
|
|
@@ -50,7 +50,7 @@ module Anima
|
|
|
50
50
|
keys.each { |key| say " #{key}" }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
desc "remove KEY", "Remove an MCP secret from encrypted
|
|
53
|
+
desc "remove KEY", "Remove an MCP secret from encrypted storage"
|
|
54
54
|
def remove(key)
|
|
55
55
|
secrets = require_mcp_secrets
|
|
56
56
|
unless secrets.list.include?(key)
|
data/lib/anima/cli/mcp.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Anima
|
|
|
12
12
|
true
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted
|
|
15
|
+
desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted storage"
|
|
16
16
|
subcommand "secrets", Secrets
|
|
17
17
|
|
|
18
18
|
desc "list", "List configured MCP servers with health status"
|
|
@@ -40,14 +40,14 @@ module Anima
|
|
|
40
40
|
|
|
41
41
|
Use -e KEY=VALUE to set environment variables (stdio servers).
|
|
42
42
|
Use -H "Header: Value" to set HTTP headers (HTTP servers).
|
|
43
|
-
Use -s KEY=VALUE to store a secret in encrypted
|
|
43
|
+
Use -s KEY=VALUE to store a secret in encrypted storage.
|
|
44
44
|
DESC
|
|
45
45
|
option :env, aliases: "-e", type: :string, repeatable: true, banner: "KEY=VALUE",
|
|
46
46
|
desc: "Environment variables (repeatable)"
|
|
47
47
|
option :header, aliases: "-H", type: :string, repeatable: true, banner: "Header: Value",
|
|
48
48
|
desc: "HTTP headers (repeatable)"
|
|
49
49
|
option :secret, aliases: "-s", type: :string, repeatable: true, banner: "KEY=VALUE",
|
|
50
|
-
desc: "Store secret in encrypted
|
|
50
|
+
desc: "Store secret in encrypted storage (repeatable)"
|
|
51
51
|
def add(name, *rest)
|
|
52
52
|
if rest.empty?
|
|
53
53
|
say "Error: missing server URL or command.", :red
|
|
@@ -87,7 +87,7 @@ module Anima
|
|
|
87
87
|
::Mcp::Config.new
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Stores secrets from -s KEY=VALUE flags in encrypted
|
|
90
|
+
# Stores secrets from -s KEY=VALUE flags in the encrypted secrets table.
|
|
91
91
|
def store_secrets(secret_strings)
|
|
92
92
|
return unless secret_strings&.any?
|
|
93
93
|
|