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/lib/tools/remember.rb
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
|
-
# Fractal-resolution zoom into
|
|
5
|
-
# on a target
|
|
4
|
+
# Fractal-resolution zoom into message history. Returns a window centered
|
|
5
|
+
# on a target message with full detail at the center and compressed context
|
|
6
6
|
# at the edges — sharp fovea, blurry periphery.
|
|
7
7
|
#
|
|
8
8
|
# Output structure:
|
|
9
9
|
# [Previous snapshots — compressed context before]
|
|
10
|
-
# [
|
|
10
|
+
# [Messages N-M — full detail, tool_responses compressed to checkmarks]
|
|
11
11
|
# [Following snapshots — compressed context after]
|
|
12
12
|
#
|
|
13
|
-
# The agent discovers target
|
|
13
|
+
# The agent discovers target messages via FTS5 search results embedded in
|
|
14
14
|
# viewport recall snippets. This tool drills down into the full context.
|
|
15
15
|
#
|
|
16
16
|
# @example
|
|
17
|
-
# remember(
|
|
17
|
+
# remember(message_id: 42)
|
|
18
18
|
class Remember < Base
|
|
19
|
-
#
|
|
20
|
-
# ±10
|
|
19
|
+
# Messages around the target to include at full resolution.
|
|
20
|
+
# ±10 messages provides sharp foveal detail while keeping output readable.
|
|
21
21
|
CONTEXT_WINDOW = 20
|
|
22
22
|
|
|
23
23
|
ROLE_LABELS = {
|
|
@@ -28,24 +28,15 @@ module Tools
|
|
|
28
28
|
|
|
29
29
|
def self.tool_name = "remember"
|
|
30
30
|
|
|
31
|
-
def self.description
|
|
32
|
-
"Recall the full context around a past event. " \
|
|
33
|
-
"Returns a fractal-resolution window: high detail at the center " \
|
|
34
|
-
"(the target event and its neighbors), compressed context at the edges " \
|
|
35
|
-
"(snapshots before and after). Use this when search results surface " \
|
|
36
|
-
"a relevant event and you need the surrounding conversation."
|
|
37
|
-
end
|
|
31
|
+
def self.description = "Recall the full conversation around a past message."
|
|
38
32
|
|
|
39
33
|
def self.input_schema
|
|
40
34
|
{
|
|
41
35
|
type: "object",
|
|
42
36
|
properties: {
|
|
43
|
-
|
|
44
|
-
type: "integer",
|
|
45
|
-
description: "The event ID to zoom into (from search results or recall snippets)"
|
|
46
|
-
}
|
|
37
|
+
message_id: {type: "integer"}
|
|
47
38
|
},
|
|
48
|
-
required: ["
|
|
39
|
+
required: ["message_id"]
|
|
49
40
|
}
|
|
50
41
|
end
|
|
51
42
|
|
|
@@ -53,12 +44,12 @@ module Tools
|
|
|
53
44
|
@session = session
|
|
54
45
|
end
|
|
55
46
|
|
|
56
|
-
# @param input [Hash] with "
|
|
57
|
-
# @return [String] fractal-resolution window around the target
|
|
47
|
+
# @param input [Hash] with "message_id"
|
|
48
|
+
# @return [String] fractal-resolution window around the target message
|
|
58
49
|
def execute(input)
|
|
59
|
-
|
|
60
|
-
target =
|
|
61
|
-
return {error: "
|
|
50
|
+
message_id = input["message_id"].to_i
|
|
51
|
+
target = Message.find_by(id: message_id)
|
|
52
|
+
return {error: "Message #{message_id} not found"} unless target
|
|
62
53
|
|
|
63
54
|
build_fractal_window(target)
|
|
64
55
|
end
|
|
@@ -67,17 +58,17 @@ module Tools
|
|
|
67
58
|
|
|
68
59
|
# Assembles the three-zone fractal window.
|
|
69
60
|
#
|
|
70
|
-
# @param target [
|
|
61
|
+
# @param target [Message] the center message
|
|
71
62
|
# @return [String] formatted fractal window
|
|
72
63
|
def build_fractal_window(target)
|
|
73
64
|
target_session = target.session
|
|
74
|
-
|
|
75
|
-
first_center_id =
|
|
76
|
-
last_center_id =
|
|
65
|
+
center_messages = fetch_center_messages(target, target_session)
|
|
66
|
+
first_center_id = center_messages.first&.id
|
|
67
|
+
last_center_id = center_messages.last&.id
|
|
77
68
|
|
|
78
69
|
sections = build_sections(
|
|
79
70
|
target_session: target_session,
|
|
80
|
-
|
|
71
|
+
center_messages: center_messages,
|
|
81
72
|
target_id: target.id,
|
|
82
73
|
first_center_id: first_center_id,
|
|
83
74
|
last_center_id: last_center_id
|
|
@@ -86,18 +77,18 @@ module Tools
|
|
|
86
77
|
end
|
|
87
78
|
|
|
88
79
|
# Builds ordered sections: header, before snapshots, center, after snapshots.
|
|
89
|
-
def build_sections(target_session:,
|
|
80
|
+
def build_sections(target_session:, center_messages:, target_id:, first_center_id:, last_center_id:)
|
|
90
81
|
sections = [session_header(target_session)]
|
|
91
82
|
|
|
92
83
|
append_snapshot_sections(sections, target_session.snapshots
|
|
93
|
-
.where("
|
|
84
|
+
.where("to_message_id < ?", first_center_id)
|
|
94
85
|
.chronological.last(3), label: "PREVIOUS CONTEXT")
|
|
95
86
|
|
|
96
|
-
sections << "── FULL CONTEXT (
|
|
97
|
-
|
|
87
|
+
sections << "── FULL CONTEXT (messages #{first_center_id}..#{last_center_id}) ──"
|
|
88
|
+
center_messages.each { |msg| sections << render_center_message(msg, target_id) }
|
|
98
89
|
|
|
99
90
|
append_snapshot_sections(sections, target_session.snapshots
|
|
100
|
-
.where("
|
|
91
|
+
.where("from_message_id > ?", last_center_id)
|
|
101
92
|
.chronological.first(3), label: "FOLLOWING CONTEXT")
|
|
102
93
|
|
|
103
94
|
sections
|
|
@@ -116,12 +107,12 @@ module Tools
|
|
|
116
107
|
snapshots.each { |snapshot| sections << format_snapshot(snapshot) }
|
|
117
108
|
end
|
|
118
109
|
|
|
119
|
-
# Fetches conversation
|
|
110
|
+
# Fetches conversation messages around the target within a fixed window.
|
|
120
111
|
#
|
|
121
|
-
# @return [Array<
|
|
122
|
-
def
|
|
112
|
+
# @return [Array<Message>] chronologically ordered
|
|
113
|
+
def fetch_center_messages(target, target_session)
|
|
123
114
|
half = CONTEXT_WINDOW / 2
|
|
124
|
-
scope = target_session.
|
|
115
|
+
scope = target_session.messages.context_messages
|
|
125
116
|
target_id = target.id
|
|
126
117
|
|
|
127
118
|
before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
|
|
@@ -130,37 +121,37 @@ module Tools
|
|
|
130
121
|
before + after
|
|
131
122
|
end
|
|
132
123
|
|
|
133
|
-
# Renders a center
|
|
134
|
-
# Conversation
|
|
124
|
+
# Renders a center message at full resolution.
|
|
125
|
+
# Conversation messages show full content. Tool calls show name + input.
|
|
135
126
|
# Tool responses compressed to status indicator.
|
|
136
127
|
#
|
|
137
|
-
# @param
|
|
138
|
-
# @param target_id [Integer] the
|
|
128
|
+
# @param message [Message]
|
|
129
|
+
# @param target_id [Integer] the message being zoomed into (marked with arrow)
|
|
139
130
|
# @return [String]
|
|
140
|
-
def
|
|
141
|
-
marker = (
|
|
142
|
-
prefix = "#{marker}
|
|
131
|
+
def render_center_message(message, target_id)
|
|
132
|
+
marker = (message.id == target_id) ? "→" : " "
|
|
133
|
+
prefix = "#{marker} message #{message.id}"
|
|
143
134
|
|
|
144
|
-
"#{prefix} #{
|
|
135
|
+
"#{prefix} #{format_message_content(message)}"
|
|
145
136
|
end
|
|
146
137
|
|
|
147
|
-
# Formats
|
|
148
|
-
def
|
|
149
|
-
data =
|
|
138
|
+
# Formats message content based on type.
|
|
139
|
+
def format_message_content(message)
|
|
140
|
+
data = message.payload
|
|
150
141
|
content = data["content"]
|
|
151
142
|
|
|
152
|
-
if ROLE_LABELS.key?(
|
|
153
|
-
"#{ROLE_LABELS[
|
|
154
|
-
elsif
|
|
143
|
+
if ROLE_LABELS.key?(message.message_type)
|
|
144
|
+
"#{ROLE_LABELS[message.message_type]}: #{content}"
|
|
145
|
+
elsif message.message_type == "tool_call"
|
|
155
146
|
format_tool_call(data)
|
|
156
|
-
elsif
|
|
147
|
+
elsif message.message_type == "tool_response"
|
|
157
148
|
status = content.to_s.start_with?("Error") ? "error" : "ok"
|
|
158
149
|
"ToolResult: [#{status}] #{data["tool_use_id"]}"
|
|
159
150
|
end
|
|
160
151
|
end
|
|
161
152
|
|
|
162
153
|
def format_tool_call(data)
|
|
163
|
-
if data["tool_name"] ==
|
|
154
|
+
if data["tool_name"] == Message::THINK_TOOL
|
|
164
155
|
"Think: #{data.dig("tool_input", "thoughts")}"
|
|
165
156
|
else
|
|
166
157
|
"Tool: #{data["tool_name"]}(#{data["tool_input"].to_json.truncate(200)})"
|
|
@@ -173,7 +164,7 @@ module Tools
|
|
|
173
164
|
# @return [String]
|
|
174
165
|
def format_snapshot(snapshot)
|
|
175
166
|
level = (snapshot.level == 2) ? "L2" : "L1"
|
|
176
|
-
"[#{level} snapshot,
|
|
167
|
+
"[#{level} snapshot, messages #{snapshot.from_message_id}..#{snapshot.to_message_id}]\n#{snapshot.text}"
|
|
177
168
|
end
|
|
178
169
|
end
|
|
179
170
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Tools
|
|
6
|
+
# Truncates oversized tool results to protect the agent's context window.
|
|
7
|
+
#
|
|
8
|
+
# When a tool returns more characters than the configured threshold,
|
|
9
|
+
# saves the full output to a temp file and returns a truncated version:
|
|
10
|
+
# first 10 lines + notice + last 10 lines. The agent can use the
|
|
11
|
+
# +read_file+ tool with offset/limit to inspect the full output.
|
|
12
|
+
#
|
|
13
|
+
# Two thresholds exist:
|
|
14
|
+
# - **Tool threshold** (~3000 chars) — for raw tool output (bash, web, etc.)
|
|
15
|
+
# - **Sub-agent threshold** (~24000 chars) — for curated sub-agent results
|
|
16
|
+
#
|
|
17
|
+
# @example Truncating a tool result
|
|
18
|
+
# ResponseTruncator.truncate(huge_string, threshold: 3000)
|
|
19
|
+
# # => "line 1\nline 2\n...\n---\n⚠️ Response truncated..."
|
|
20
|
+
module ResponseTruncator
|
|
21
|
+
HEAD_LINES = 10
|
|
22
|
+
TAIL_LINES = 10
|
|
23
|
+
|
|
24
|
+
# Attribution prefix for messages routed from sub-agent to parent.
|
|
25
|
+
# Shared by {Events::Subscribers::SubagentMessageRouter} and
|
|
26
|
+
# {Tools::MarkGoalCompleted} to keep formatting consistent.
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
|
|
28
|
+
|
|
29
|
+
NOTICE = <<~NOTICE.strip
|
|
30
|
+
---
|
|
31
|
+
⚠️ Response truncated (%<total>d lines total%<reason>s). Full output saved to: %<path>s
|
|
32
|
+
Use `read_file` tool with offset/limit to inspect specific sections.
|
|
33
|
+
---
|
|
34
|
+
NOTICE
|
|
35
|
+
|
|
36
|
+
# Truncates content that exceeds the character threshold.
|
|
37
|
+
#
|
|
38
|
+
# @param content [Object] the tool result to (maybe) truncate; non-strings pass through unchanged
|
|
39
|
+
# @param threshold [Integer] character limit before truncation kicks in
|
|
40
|
+
# @param reason [String, nil] why truncation occurred (e.g. "bash output displays first/last 10 lines")
|
|
41
|
+
# @return [Object] original value if non-String/under threshold/few lines, truncated String otherwise
|
|
42
|
+
def self.truncate(content, threshold:, reason: nil)
|
|
43
|
+
return content unless content.is_a?(String)
|
|
44
|
+
return content if content.length <= threshold
|
|
45
|
+
|
|
46
|
+
lines = content.lines
|
|
47
|
+
total = lines.size
|
|
48
|
+
return content if total <= HEAD_LINES + TAIL_LINES
|
|
49
|
+
|
|
50
|
+
path = save_full_output(content)
|
|
51
|
+
head = lines.first(HEAD_LINES).join
|
|
52
|
+
tail = lines.last(TAIL_LINES).join
|
|
53
|
+
reason_text = reason ? " — #{reason}" : ""
|
|
54
|
+
notice = format(NOTICE, total: total, path: path, reason: reason_text)
|
|
55
|
+
|
|
56
|
+
"#{head}\n#{notice}\n\n#{tail}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Saves full content to a temp file that persists until system cleanup.
|
|
60
|
+
#
|
|
61
|
+
# @param content [String] the full tool result
|
|
62
|
+
# @return [String] absolute path to the saved file
|
|
63
|
+
def self.save_full_output(content)
|
|
64
|
+
file = Tempfile.create(["tool_result_", ".txt"])
|
|
65
|
+
file.write(content)
|
|
66
|
+
file.close
|
|
67
|
+
file.path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -21,10 +21,9 @@ module Tools
|
|
|
21
21
|
|
|
22
22
|
# Builds description dynamically to include available specialists.
|
|
23
23
|
def self.description
|
|
24
|
-
base = "
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"Address it via @name to send follow-up instructions."
|
|
24
|
+
base = "Need a specific skill set for the job? Bring in a specialist. " \
|
|
25
|
+
"Its messages appear in yours; any message containing " \
|
|
26
|
+
"@nickname is forwarded — even casual mentions will wake it."
|
|
28
27
|
|
|
29
28
|
registry = Agents::Registry.instance
|
|
30
29
|
return base unless registry.any?
|
|
@@ -39,16 +38,9 @@ module Tools
|
|
|
39
38
|
type: "object",
|
|
40
39
|
properties: {
|
|
41
40
|
name: name_property,
|
|
42
|
-
task: {
|
|
43
|
-
type: "string",
|
|
44
|
-
description: "What the specialist should do (persisted as its first user message)"
|
|
45
|
-
},
|
|
46
|
-
expected_output: {
|
|
47
|
-
type: "string",
|
|
48
|
-
description: "Description of the expected deliverable"
|
|
49
|
-
}
|
|
41
|
+
task: {type: "string", description: "State the goal — the specialist knows its method."}
|
|
50
42
|
},
|
|
51
|
-
required: %w[name task
|
|
43
|
+
required: %w[name task]
|
|
52
44
|
}
|
|
53
45
|
end
|
|
54
46
|
|
|
@@ -57,7 +49,7 @@ module Tools
|
|
|
57
49
|
registry = Agents::Registry.instance
|
|
58
50
|
prop = {
|
|
59
51
|
type: "string",
|
|
60
|
-
description: "
|
|
52
|
+
description: "Specialist to spawn."
|
|
61
53
|
}
|
|
62
54
|
prop[:enum] = registry.names if registry.any?
|
|
63
55
|
prop
|
|
@@ -76,46 +68,44 @@ module Tools
|
|
|
76
68
|
# persists the task as a user message, and queues background processing.
|
|
77
69
|
# Returns immediately (non-blocking).
|
|
78
70
|
#
|
|
79
|
-
# @param input [Hash<String, Object>] with "name"
|
|
71
|
+
# @param input [Hash<String, Object>] with "name" and "task"
|
|
80
72
|
# @return [String] confirmation with child session ID
|
|
81
73
|
# @return [Hash{Symbol => String}] with :error key on validation failure
|
|
82
74
|
def execute(input)
|
|
83
75
|
task = input["task"].to_s.strip
|
|
84
|
-
expected_output = input["expected_output"].to_s.strip
|
|
85
76
|
name = input["name"].to_s.strip
|
|
86
77
|
|
|
87
78
|
return {error: "Name cannot be blank"} if name.empty?
|
|
88
79
|
return {error: "Task cannot be blank"} if task.empty?
|
|
89
|
-
return {error: "Expected output cannot be blank"} if expected_output.empty?
|
|
90
80
|
|
|
91
81
|
definition = @agent_registry.get(name)
|
|
92
82
|
return {error: "Unknown agent: #{name}"} unless definition
|
|
93
83
|
|
|
94
|
-
child = spawn_child(definition, task
|
|
84
|
+
child = spawn_child(definition, task)
|
|
95
85
|
nickname = child.name
|
|
96
86
|
"Specialist @#{nickname} spawned (session #{child.id}). " \
|
|
97
87
|
"Its messages will appear in your conversation. " \
|
|
98
|
-
"Reply with @#{nickname} to send it instructions
|
|
88
|
+
"Reply with @#{nickname} to send it instructions — " \
|
|
89
|
+
"any message mentioning @#{nickname} is forwarded, even in narration."
|
|
99
90
|
end
|
|
100
91
|
|
|
101
92
|
private
|
|
102
93
|
|
|
103
|
-
def spawn_child(definition, task
|
|
104
|
-
prompt = build_prompt(definition, expected_output)
|
|
94
|
+
def spawn_child(definition, task)
|
|
105
95
|
child = Session.create!(
|
|
106
96
|
parent_session_id: @session.id,
|
|
107
|
-
prompt:
|
|
97
|
+
prompt: build_prompt(definition),
|
|
108
98
|
granted_tools: definition.tools
|
|
109
99
|
)
|
|
110
|
-
child
|
|
100
|
+
pin_goal_and_frame(child, task)
|
|
111
101
|
assign_nickname_via_brain(child)
|
|
112
102
|
child.broadcast_children_update_to_parent
|
|
113
103
|
AgentRequestJob.perform_later(child.id)
|
|
114
104
|
child
|
|
115
105
|
end
|
|
116
106
|
|
|
117
|
-
def build_prompt(definition
|
|
118
|
-
"#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}
|
|
107
|
+
def build_prompt(definition)
|
|
108
|
+
"#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}"
|
|
119
109
|
end
|
|
120
110
|
end
|
|
121
111
|
end
|
data/lib/tools/spawn_subagent.rb
CHANGED
|
@@ -14,29 +14,22 @@ module Tools
|
|
|
14
14
|
class SpawnSubagent < Base
|
|
15
15
|
include SubagentPrompts
|
|
16
16
|
|
|
17
|
-
GENERIC_PROMPT = "
|
|
17
|
+
GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
|
|
18
18
|
|
|
19
19
|
def self.tool_name = "spawn_subagent"
|
|
20
20
|
|
|
21
21
|
def self.description
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
22
|
+
"Task feels like a sidequest or a context-switch? Hand it off. " \
|
|
23
|
+
"Inherits your context; its messages appear in yours. " \
|
|
24
|
+
"Any message containing @nickname is forwarded — " \
|
|
25
|
+
"even casual mentions will wake the sub-agent."
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def self.input_schema
|
|
29
29
|
{
|
|
30
30
|
type: "object",
|
|
31
31
|
properties: {
|
|
32
|
-
task: {
|
|
33
|
-
type: "string",
|
|
34
|
-
description: "What the sub-agent should do (persisted as its first user message)"
|
|
35
|
-
},
|
|
36
|
-
expected_output: {
|
|
37
|
-
type: "string",
|
|
38
|
-
description: "Description of the expected deliverable"
|
|
39
|
-
},
|
|
32
|
+
task: {type: "string"},
|
|
40
33
|
tools: {
|
|
41
34
|
type: "array",
|
|
42
35
|
items: {type: "string"},
|
|
@@ -45,7 +38,7 @@ module Tools
|
|
|
45
38
|
"Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
|
|
46
39
|
}
|
|
47
40
|
},
|
|
48
|
-
required: %w[task
|
|
41
|
+
required: %w[task]
|
|
49
42
|
}
|
|
50
43
|
end
|
|
51
44
|
|
|
@@ -58,37 +51,36 @@ module Tools
|
|
|
58
51
|
# persists the task as a user message, and queues background processing.
|
|
59
52
|
# Returns immediately after brain completes (blocking for ~200ms).
|
|
60
53
|
#
|
|
61
|
-
# @param input [Hash<String, Object>] with "task"
|
|
54
|
+
# @param input [Hash<String, Object>] with "task" and optional "tools"
|
|
62
55
|
# @return [String] confirmation with child session ID and @nickname
|
|
63
56
|
# @return [Hash{Symbol => String}] with :error key on validation failure
|
|
64
57
|
def execute(input)
|
|
65
58
|
task = input["task"].to_s.strip
|
|
66
|
-
expected_output = input["expected_output"].to_s.strip
|
|
67
59
|
|
|
68
60
|
return {error: "Task cannot be blank"} if task.empty?
|
|
69
|
-
return {error: "Expected output cannot be blank"} if expected_output.empty?
|
|
70
61
|
|
|
71
62
|
tools = normalize_tools(input["tools"])
|
|
72
63
|
|
|
73
64
|
error = validate_tools(tools)
|
|
74
65
|
return error if error
|
|
75
66
|
|
|
76
|
-
child = spawn_child(task,
|
|
67
|
+
child = spawn_child(task, tools)
|
|
77
68
|
nickname = child.name
|
|
78
69
|
"Sub-agent @#{nickname} spawned (session #{child.id}). " \
|
|
79
70
|
"Its messages will appear in your conversation. " \
|
|
80
|
-
"Reply with @#{nickname} to send it instructions
|
|
71
|
+
"Reply with @#{nickname} to send it instructions — " \
|
|
72
|
+
"any message mentioning @#{nickname} is forwarded, even in narration."
|
|
81
73
|
end
|
|
82
74
|
|
|
83
75
|
private
|
|
84
76
|
|
|
85
|
-
def spawn_child(task,
|
|
77
|
+
def spawn_child(task, granted_tools)
|
|
86
78
|
child = Session.create!(
|
|
87
79
|
parent_session_id: @session.id,
|
|
88
|
-
prompt:
|
|
80
|
+
prompt: GENERIC_PROMPT,
|
|
89
81
|
granted_tools: granted_tools
|
|
90
82
|
)
|
|
91
|
-
child
|
|
83
|
+
pin_goal_and_frame(child, task)
|
|
92
84
|
assign_nickname_via_brain(child)
|
|
93
85
|
child.broadcast_children_update_to_parent
|
|
94
86
|
AgentRequestJob.perform_later(child.id)
|
|
@@ -1,25 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
|
-
# Shared prompt fragments and
|
|
4
|
+
# Shared prompt fragments and spawn logic for tools that create sub-agent sessions.
|
|
5
5
|
# Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
|
|
6
6
|
module SubagentPrompts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
# Prepended to every sub-agent's stored prompt after nickname assignment.
|
|
8
|
+
# Establishes identity before any other instruction.
|
|
9
|
+
IDENTITY_TEMPLATE = "You are @%s, a sub-agent of the primary agent."
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
COMMUNICATION_INSTRUCTION = "Your messages reach the parent automatically. " \
|
|
12
|
+
"Ask if you need clarification — the parent can reply."
|
|
13
|
+
|
|
14
|
+
# Framing message inserted as the sub-agent's first user message.
|
|
15
|
+
# This is the "brake" between inherited parent context and the sub-agent's
|
|
16
|
+
# own task — without it, the model continues the parent's trajectory.
|
|
17
|
+
FORK_FRAMING_MESSAGE = "You were spawned to help with a single task. " \
|
|
18
|
+
"The messages above are the parent agent's context — background for your work, " \
|
|
19
|
+
"but the parent's goals are not yours. " \
|
|
20
|
+
"Your sole task is described in your Goal."
|
|
12
21
|
|
|
13
22
|
private
|
|
14
23
|
|
|
15
|
-
#
|
|
24
|
+
# Creates the sub-agent's Goal from the task description and inserts
|
|
25
|
+
# the framing message as the first user message.
|
|
26
|
+
#
|
|
27
|
+
# @param child [Session] the newly created child session
|
|
28
|
+
# @param task [String] the task description to pin as the sole Goal
|
|
29
|
+
# @return [void]
|
|
30
|
+
def pin_goal_and_frame(child, task)
|
|
31
|
+
child.goals.create!(description: task)
|
|
32
|
+
child.create_user_message(FORK_FRAMING_MESSAGE)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the analytical brain synchronously to assign a nickname,
|
|
36
|
+
# then prepends identity context to the stored prompt.
|
|
16
37
|
# Falls back to a sequential "agent-N" name on any failure.
|
|
38
|
+
# Identity injection runs in +ensure+ so it applies to both
|
|
39
|
+
# brain-assigned and fallback nicknames.
|
|
17
40
|
def assign_nickname_via_brain(child)
|
|
18
41
|
AnalyticalBrain::Runner.new(child).call
|
|
19
42
|
child.reload
|
|
20
43
|
rescue => error
|
|
21
44
|
Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
|
|
22
45
|
child.update!(name: fallback_nickname)
|
|
46
|
+
ensure
|
|
47
|
+
inject_identity_context(child)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Prepends identity context (nickname + sub-agent status) to the child's
|
|
51
|
+
# stored prompt. Called after nickname assignment so the sub-agent knows
|
|
52
|
+
# who it is from first token.
|
|
53
|
+
#
|
|
54
|
+
# @param child [Session] the child session with a nickname already set
|
|
55
|
+
# @return [void]
|
|
56
|
+
def inject_identity_context(child)
|
|
57
|
+
identity = format(IDENTITY_TEMPLATE, child.name)
|
|
58
|
+
child.update!(prompt: "#{identity}\n#{child.prompt}")
|
|
23
59
|
end
|
|
24
60
|
|
|
25
61
|
def fallback_nickname
|
data/lib/tools/think.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Tools
|
|
|
13
13
|
# - **inner** (default) — silent reasoning, visible only in verbose/debug
|
|
14
14
|
# - **aloud** — narration shown in all view modes with a thought bubble
|
|
15
15
|
#
|
|
16
|
+
# The +maxLength+ on thoughts is controlled by +thinking_budget+ in settings.
|
|
17
|
+
# Sub-agents receive half the main agent's budget — their tasks are scoped
|
|
18
|
+
# and less complex, so runaway reasoning is a stronger signal of confusion.
|
|
19
|
+
#
|
|
16
20
|
# @example Silent planning between tool calls
|
|
17
21
|
# think(thoughts: "Three auth failures — likely a config issue, not individual tests.")
|
|
18
22
|
#
|
|
@@ -21,30 +25,42 @@ module Tools
|
|
|
21
25
|
class Think < Base
|
|
22
26
|
def self.tool_name = "think"
|
|
23
27
|
|
|
24
|
-
def self.description
|
|
25
|
-
"Express your internal reasoning between tool calls. " \
|
|
26
|
-
"Use this to analyze intermediate results, plan next steps, or make decisions before continuing. " \
|
|
27
|
-
"Set visibility to \"aloud\" when you want the user to see your thought process."
|
|
28
|
-
end
|
|
28
|
+
def self.description = "Think out loud or silently."
|
|
29
29
|
|
|
30
|
+
# Schema is static — maxLength is injected at runtime by the registry
|
|
31
|
+
# via {#dynamic_schema} when session context is available.
|
|
30
32
|
def self.input_schema
|
|
31
33
|
{
|
|
32
34
|
type: "object",
|
|
33
35
|
properties: {
|
|
34
|
-
thoughts: {
|
|
35
|
-
type: "string",
|
|
36
|
-
description: "Your reasoning, analysis, or internal monologue"
|
|
37
|
-
},
|
|
36
|
+
thoughts: {type: "string"},
|
|
38
37
|
visibility: {
|
|
39
38
|
type: "string",
|
|
40
39
|
enum: ["inner", "aloud"],
|
|
41
|
-
description: "
|
|
40
|
+
description: "inner (default) is silent. aloud is shown to the user."
|
|
42
41
|
}
|
|
43
42
|
},
|
|
44
43
|
required: ["thoughts"]
|
|
45
44
|
}
|
|
46
45
|
end
|
|
47
46
|
|
|
47
|
+
# @param session [Session, nil] current session for budget calculation
|
|
48
|
+
def initialize(session: nil, **)
|
|
49
|
+
@session = session
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the tool schema with a thinking budget applied as maxLength
|
|
53
|
+
# on the thoughts property. Sub-agents get half the budget.
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash] Anthropic tool schema with maxLength constraint
|
|
56
|
+
def dynamic_schema
|
|
57
|
+
schema = self.class.schema.deep_dup
|
|
58
|
+
budget = Anima::Settings.thinking_budget
|
|
59
|
+
budget /= 2 if @session&.sub_agent?
|
|
60
|
+
schema[:input_schema][:properties][:thoughts][:maxLength] = budget
|
|
61
|
+
schema
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
# @param input [Hash] with "thoughts" and optional "visibility"
|
|
49
65
|
# @return [String] acknowledgement — the value is in the call, not the result
|
|
50
66
|
def execute(input)
|
data/lib/tools/web_get.rb
CHANGED
|
@@ -15,13 +15,13 @@ module Tools
|
|
|
15
15
|
class WebGet < Base
|
|
16
16
|
def self.tool_name = "web_get"
|
|
17
17
|
|
|
18
|
-
def self.description = "Fetch
|
|
18
|
+
def self.description = "Fetch a URL."
|
|
19
19
|
|
|
20
20
|
def self.input_schema
|
|
21
21
|
{
|
|
22
22
|
type: "object",
|
|
23
23
|
properties: {
|
|
24
|
-
url: {type: "string"
|
|
24
|
+
url: {type: "string"}
|
|
25
25
|
},
|
|
26
26
|
required: ["url"]
|
|
27
27
|
}
|
|
@@ -47,10 +47,11 @@ module Tools
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
response = HTTParty.get(url, timeout: timeout, follow_redirects: false, ssl_ca_file: Certifi.where)
|
|
50
|
-
body = truncate_body(response.body.to_s)
|
|
51
50
|
content_type = response.content_type || "text/plain"
|
|
51
|
+
body = response.body.to_s
|
|
52
|
+
body = strip_html_noise(body) if content_type.include?("text/html")
|
|
52
53
|
|
|
53
|
-
{body: body, content_type: content_type}
|
|
54
|
+
{body: truncate_body(body), content_type: content_type}
|
|
54
55
|
rescue URI::InvalidURIError => error
|
|
55
56
|
{error: "Invalid URL: #{error.message}"}
|
|
56
57
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
@@ -68,5 +69,23 @@ module Tools
|
|
|
68
69
|
body.byteslice(0, max_bytes).scrub +
|
|
69
70
|
"\n\n[Truncated: response exceeded #{max_bytes} bytes]"
|
|
70
71
|
end
|
|
72
|
+
|
|
73
|
+
# First-stage noise stripping — runs before truncation so that the
|
|
74
|
+
# byte budget is spent on content, not on scripts/SVGs/metadata.
|
|
75
|
+
# Each pattern targets one tag type for easy maintenance.
|
|
76
|
+
# The decorator applies a second, structure-aware pass via Nokogiri.
|
|
77
|
+
HTML_NOISE_PATTERNS = [
|
|
78
|
+
%r{<head\b[^>]*>.*?</head>}mi, # metadata, link/meta tags
|
|
79
|
+
%r{<script\b[^>]*>.*?</script>}mi, # JavaScript
|
|
80
|
+
%r{<style\b[^>]*>.*?</style>}mi, # CSS
|
|
81
|
+
%r{<svg\b[^>]*>.*?</svg>}mi, # inline graphics
|
|
82
|
+
%r{<template\b[^>]*>.*?</template>}mi, # deferred markup
|
|
83
|
+
%r{<noscript\b[^>]*>.*?</noscript>}mi # JS-disabled fallbacks
|
|
84
|
+
].freeze
|
|
85
|
+
private_constant :HTML_NOISE_PATTERNS
|
|
86
|
+
|
|
87
|
+
def strip_html_noise(html)
|
|
88
|
+
HTML_NOISE_PATTERNS.reduce(html) { |text, pattern| text.gsub(pattern, "") }
|
|
89
|
+
end
|
|
71
90
|
end
|
|
72
91
|
end
|