anima-core 1.0.2 → 1.1.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/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +90 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Fractal-resolution zoom into event history. Returns a window centered
|
|
5
|
+
# on a target event with full detail at the center and compressed context
|
|
6
|
+
# at the edges — sharp fovea, blurry periphery.
|
|
7
|
+
#
|
|
8
|
+
# Output structure:
|
|
9
|
+
# [Previous snapshots — compressed context before]
|
|
10
|
+
# [Events N-M — full detail, tool_responses compressed to checkmarks]
|
|
11
|
+
# [Following snapshots — compressed context after]
|
|
12
|
+
#
|
|
13
|
+
# The agent discovers target events via FTS5 search results embedded in
|
|
14
|
+
# viewport recall snippets. This tool drills down into the full context.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# remember(event_id: 42)
|
|
18
|
+
class Remember < Base
|
|
19
|
+
# Events around the target to include at full resolution.
|
|
20
|
+
# ±10 events provides sharp foveal detail while keeping output readable.
|
|
21
|
+
CONTEXT_WINDOW = 20
|
|
22
|
+
|
|
23
|
+
ROLE_LABELS = {
|
|
24
|
+
"user_message" => "User",
|
|
25
|
+
"agent_message" => "Assistant",
|
|
26
|
+
"system_message" => "System"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.tool_name = "remember"
|
|
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
|
|
38
|
+
|
|
39
|
+
def self.input_schema
|
|
40
|
+
{
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
event_id: {
|
|
44
|
+
type: "integer",
|
|
45
|
+
description: "The event ID to zoom into (from search results or recall snippets)"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ["event_id"]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(session:, **)
|
|
53
|
+
@session = session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param input [Hash] with "event_id"
|
|
57
|
+
# @return [String] fractal-resolution window around the target event
|
|
58
|
+
def execute(input)
|
|
59
|
+
event_id = input["event_id"].to_i
|
|
60
|
+
target = Event.find_by(id: event_id)
|
|
61
|
+
return {error: "Event #{event_id} not found"} unless target
|
|
62
|
+
|
|
63
|
+
build_fractal_window(target)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Assembles the three-zone fractal window.
|
|
69
|
+
#
|
|
70
|
+
# @param target [Event] the center event
|
|
71
|
+
# @return [String] formatted fractal window
|
|
72
|
+
def build_fractal_window(target)
|
|
73
|
+
target_session = target.session
|
|
74
|
+
center_events = fetch_center_events(target, target_session)
|
|
75
|
+
first_center_id = center_events.first&.id
|
|
76
|
+
last_center_id = center_events.last&.id
|
|
77
|
+
|
|
78
|
+
sections = build_sections(
|
|
79
|
+
target_session: target_session,
|
|
80
|
+
center_events: center_events,
|
|
81
|
+
target_id: target.id,
|
|
82
|
+
first_center_id: first_center_id,
|
|
83
|
+
last_center_id: last_center_id
|
|
84
|
+
)
|
|
85
|
+
sections.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Builds ordered sections: header, before snapshots, center, after snapshots.
|
|
89
|
+
def build_sections(target_session:, center_events:, target_id:, first_center_id:, last_center_id:)
|
|
90
|
+
sections = [session_header(target_session)]
|
|
91
|
+
|
|
92
|
+
append_snapshot_sections(sections, target_session.snapshots
|
|
93
|
+
.where("to_event_id < ?", first_center_id)
|
|
94
|
+
.chronological.last(3), label: "PREVIOUS CONTEXT")
|
|
95
|
+
|
|
96
|
+
sections << "── FULL CONTEXT (events #{first_center_id}..#{last_center_id}) ──"
|
|
97
|
+
center_events.each { |event| sections << render_center_event(event, target_id) }
|
|
98
|
+
|
|
99
|
+
append_snapshot_sections(sections, target_session.snapshots
|
|
100
|
+
.where("from_event_id > ?", last_center_id)
|
|
101
|
+
.chronological.first(3), label: "FOLLOWING CONTEXT")
|
|
102
|
+
|
|
103
|
+
sections
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def session_header(target_session)
|
|
107
|
+
label = target_session.name || "Session ##{target_session.id}"
|
|
108
|
+
"── recalled from: #{label} ──"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Appends snapshot sections if any exist.
|
|
112
|
+
def append_snapshot_sections(sections, snapshots, label:)
|
|
113
|
+
return if snapshots.empty?
|
|
114
|
+
|
|
115
|
+
sections << "── #{label} (compressed) ──"
|
|
116
|
+
snapshots.each { |snapshot| sections << format_snapshot(snapshot) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fetches conversation events around the target within a fixed window.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<Event>] chronologically ordered
|
|
122
|
+
def fetch_center_events(target, target_session)
|
|
123
|
+
half = CONTEXT_WINDOW / 2
|
|
124
|
+
scope = target_session.events.context_events.deliverable
|
|
125
|
+
target_id = target.id
|
|
126
|
+
|
|
127
|
+
before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
|
|
128
|
+
after = scope.where("id > ?", target_id).reorder(id: :asc).limit(half).to_a
|
|
129
|
+
|
|
130
|
+
before + after
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Renders a center event at full resolution.
|
|
134
|
+
# Conversation events show full content. Tool calls show name + input.
|
|
135
|
+
# Tool responses compressed to status indicator.
|
|
136
|
+
#
|
|
137
|
+
# @param event [Event]
|
|
138
|
+
# @param target_id [Integer] the event being zoomed into (marked with arrow)
|
|
139
|
+
# @return [String]
|
|
140
|
+
def render_center_event(event, target_id)
|
|
141
|
+
marker = (event.id == target_id) ? "→" : " "
|
|
142
|
+
prefix = "#{marker} event #{event.id}"
|
|
143
|
+
|
|
144
|
+
"#{prefix} #{format_event_content(event)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Formats event content based on type.
|
|
148
|
+
def format_event_content(event)
|
|
149
|
+
data = event.payload
|
|
150
|
+
content = data["content"]
|
|
151
|
+
|
|
152
|
+
if ROLE_LABELS.key?(event.event_type)
|
|
153
|
+
"#{ROLE_LABELS[event.event_type]}: #{content}"
|
|
154
|
+
elsif event.event_type == "tool_call"
|
|
155
|
+
format_tool_call(data)
|
|
156
|
+
elsif event.event_type == "tool_response"
|
|
157
|
+
status = content.to_s.start_with?("Error") ? "error" : "ok"
|
|
158
|
+
"ToolResult: [#{status}] #{data["tool_use_id"]}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def format_tool_call(data)
|
|
163
|
+
if data["tool_name"] == Event::THINK_TOOL
|
|
164
|
+
"Think: #{data.dig("tool_input", "thoughts")}"
|
|
165
|
+
else
|
|
166
|
+
"Tool: #{data["tool_name"]}(#{data["tool_input"].to_json.truncate(200)})"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Formats a snapshot as compressed context.
|
|
171
|
+
#
|
|
172
|
+
# @param snapshot [Snapshot]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def format_snapshot(snapshot)
|
|
175
|
+
level = (snapshot.level == 2) ? "L2" : "L1"
|
|
176
|
+
"[#{level} snapshot, events #{snapshot.from_event_id}..#{snapshot.to_event_id}]\n#{snapshot.text}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -5,7 +5,12 @@ module Tools
|
|
|
5
5
|
# The specialist has a predefined system prompt and tool set defined
|
|
6
6
|
# in its Markdown definition file under agents/.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# Nickname assignment is handled by the {AnalyticalBrain::Runner} which
|
|
9
|
+
# runs synchronously at spawn time, generating a unique nickname based
|
|
10
|
+
# on the task — same as generic sub-agents.
|
|
11
|
+
#
|
|
12
|
+
# Results are delivered through natural text messages routed by
|
|
13
|
+
# {Events::Subscribers::SubagentMessageRouter}.
|
|
9
14
|
#
|
|
10
15
|
# @see Agents::Registry
|
|
11
16
|
# @see Agents::Definition
|
|
@@ -17,7 +22,9 @@ module Tools
|
|
|
17
22
|
# Builds description dynamically to include available specialists.
|
|
18
23
|
def self.description
|
|
19
24
|
base = "Spawn a named specialist sub-agent to work on a task autonomously. " \
|
|
20
|
-
"The specialist has a predefined role, system prompt, and tool set."
|
|
25
|
+
"The specialist has a predefined role, system prompt, and tool set. " \
|
|
26
|
+
"Its text messages are forwarded to you automatically. " \
|
|
27
|
+
"Address it via @name to send follow-up instructions."
|
|
21
28
|
|
|
22
29
|
registry = Agents::Registry.instance
|
|
23
30
|
return base unless registry.any?
|
|
@@ -34,7 +41,7 @@ module Tools
|
|
|
34
41
|
name: name_property,
|
|
35
42
|
task: {
|
|
36
43
|
type: "string",
|
|
37
|
-
description: "What the specialist should do (
|
|
44
|
+
description: "What the specialist should do (persisted as its first user message)"
|
|
38
45
|
},
|
|
39
46
|
expected_output: {
|
|
40
47
|
type: "string",
|
|
@@ -66,7 +73,8 @@ module Tools
|
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
# Creates a child session with the specialist's predefined prompt and tools,
|
|
69
|
-
#
|
|
76
|
+
# persists the task as a user message, and queues background processing.
|
|
77
|
+
# Returns immediately (non-blocking).
|
|
70
78
|
#
|
|
71
79
|
# @param input [Hash<String, Object>] with "name", "task", and "expected_output"
|
|
72
80
|
# @return [String] confirmation with child session ID
|
|
@@ -84,7 +92,10 @@ module Tools
|
|
|
84
92
|
return {error: "Unknown agent: #{name}"} unless definition
|
|
85
93
|
|
|
86
94
|
child = spawn_child(definition, task, expected_output)
|
|
87
|
-
|
|
95
|
+
nickname = child.name
|
|
96
|
+
"Specialist @#{nickname} spawned (session #{child.id}). " \
|
|
97
|
+
"Its messages will appear in your conversation. " \
|
|
98
|
+
"Reply with @#{nickname} to send it instructions."
|
|
88
99
|
end
|
|
89
100
|
|
|
90
101
|
private
|
|
@@ -94,16 +105,17 @@ module Tools
|
|
|
94
105
|
child = Session.create!(
|
|
95
106
|
parent_session_id: @session.id,
|
|
96
107
|
prompt: prompt,
|
|
97
|
-
granted_tools: definition.tools
|
|
98
|
-
name: definition.name
|
|
108
|
+
granted_tools: definition.tools
|
|
99
109
|
)
|
|
100
|
-
|
|
110
|
+
child.create_user_event(task)
|
|
111
|
+
assign_nickname_via_brain(child)
|
|
112
|
+
child.broadcast_children_update_to_parent
|
|
101
113
|
AgentRequestJob.perform_later(child.id)
|
|
102
114
|
child
|
|
103
115
|
end
|
|
104
116
|
|
|
105
117
|
def build_prompt(definition, expected_output)
|
|
106
|
-
"#{definition.prompt}\n\n#{
|
|
118
|
+
"#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}\n\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}"
|
|
107
119
|
end
|
|
108
120
|
end
|
|
109
121
|
end
|
data/lib/tools/spawn_subagent.rb
CHANGED
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
module Tools
|
|
4
4
|
# Spawns a generic child session that works on a task autonomously.
|
|
5
5
|
# The sub-agent inherits the parent's viewport context at fork time,
|
|
6
|
-
# runs via {AgentRequestJob}, and
|
|
7
|
-
#
|
|
6
|
+
# runs via {AgentRequestJob}, and communicates with the parent through
|
|
7
|
+
# natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
|
|
8
|
+
#
|
|
9
|
+
# Nickname assignment is handled by the {AnalyticalBrain::Runner} which
|
|
10
|
+
# runs synchronously at spawn time — the same brain that manages skills,
|
|
11
|
+
# goals, and workflows for the main session.
|
|
8
12
|
#
|
|
9
13
|
# For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
|
|
10
14
|
class SpawnSubagent < Base
|
|
11
15
|
include SubagentPrompts
|
|
12
16
|
|
|
13
|
-
GENERIC_PROMPT = "You are a focused sub-agent. #{
|
|
17
|
+
GENERIC_PROMPT = "You are a focused sub-agent. #{COMMUNICATION_INSTRUCTION}\n"
|
|
14
18
|
|
|
15
19
|
def self.tool_name = "spawn_subagent"
|
|
16
20
|
|
|
17
21
|
def self.description
|
|
18
22
|
"Spawn a generic sub-agent to work on a task autonomously. " \
|
|
19
23
|
"The sub-agent inherits your conversation context, works independently, " \
|
|
20
|
-
"and
|
|
24
|
+
"and its text messages are forwarded to you automatically. " \
|
|
25
|
+
"Address it via @nickname to send follow-up instructions."
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def self.input_schema
|
|
@@ -26,7 +31,7 @@ module Tools
|
|
|
26
31
|
properties: {
|
|
27
32
|
task: {
|
|
28
33
|
type: "string",
|
|
29
|
-
description: "What the sub-agent should do (
|
|
34
|
+
description: "What the sub-agent should do (persisted as its first user message)"
|
|
30
35
|
},
|
|
31
36
|
expected_output: {
|
|
32
37
|
type: "string",
|
|
@@ -36,7 +41,7 @@ module Tools
|
|
|
36
41
|
type: "array",
|
|
37
42
|
items: {type: "string"},
|
|
38
43
|
description: "Tool names to grant the sub-agent. " \
|
|
39
|
-
"Omit for all standard tools. Empty array for pure reasoning
|
|
44
|
+
"Omit for all standard tools. Empty array for pure reasoning. " \
|
|
40
45
|
"Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
|
|
41
46
|
}
|
|
42
47
|
},
|
|
@@ -49,11 +54,12 @@ module Tools
|
|
|
49
54
|
@session = session
|
|
50
55
|
end
|
|
51
56
|
|
|
52
|
-
# Creates a child session,
|
|
53
|
-
# queues background processing.
|
|
57
|
+
# Creates a child session, runs the analytical brain to assign a nickname,
|
|
58
|
+
# persists the task as a user message, and queues background processing.
|
|
59
|
+
# Returns immediately after brain completes (blocking for ~200ms).
|
|
54
60
|
#
|
|
55
61
|
# @param input [Hash<String, Object>] with "task", "expected_output", and optional "tools"
|
|
56
|
-
# @return [String] confirmation with child session ID
|
|
62
|
+
# @return [String] confirmation with child session ID and @nickname
|
|
57
63
|
# @return [Hash{Symbol => String}] with :error key on validation failure
|
|
58
64
|
def execute(input)
|
|
59
65
|
task = input["task"].to_s.strip
|
|
@@ -68,7 +74,10 @@ module Tools
|
|
|
68
74
|
return error if error
|
|
69
75
|
|
|
70
76
|
child = spawn_child(task, expected_output, tools)
|
|
71
|
-
|
|
77
|
+
nickname = child.name
|
|
78
|
+
"Sub-agent @#{nickname} spawned (session #{child.id}). " \
|
|
79
|
+
"Its messages will appear in your conversation. " \
|
|
80
|
+
"Reply with @#{nickname} to send it instructions."
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
private
|
|
@@ -79,7 +88,9 @@ module Tools
|
|
|
79
88
|
prompt: "#{GENERIC_PROMPT}\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}",
|
|
80
89
|
granted_tools: granted_tools
|
|
81
90
|
)
|
|
82
|
-
|
|
91
|
+
child.create_user_event(task)
|
|
92
|
+
assign_nickname_via_brain(child)
|
|
93
|
+
child.broadcast_children_update_to_parent
|
|
83
94
|
AgentRequestJob.perform_later(child.id)
|
|
84
95
|
child
|
|
85
96
|
end
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
|
-
# Shared prompt fragments for tools that spawn sub-agent sessions.
|
|
4
|
+
# Shared prompt fragments and nickname logic for tools that spawn sub-agent sessions.
|
|
5
5
|
# Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
|
|
6
6
|
module SubagentPrompts
|
|
7
|
-
|
|
8
|
-
"
|
|
7
|
+
COMMUNICATION_INSTRUCTION = "Your text messages are automatically forwarded to the parent agent. " \
|
|
8
|
+
"When you finish, write your final summary and stop — no special tool needed. " \
|
|
9
|
+
"If you need clarification, just ask — the parent can reply."
|
|
9
10
|
|
|
10
11
|
EXPECTED_DELIVERABLE_PREFIX = "Expected deliverable: "
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Runs the analytical brain synchronously to assign a nickname.
|
|
16
|
+
# Falls back to a sequential "agent-N" name on any failure.
|
|
17
|
+
def assign_nickname_via_brain(child)
|
|
18
|
+
AnalyticalBrain::Runner.new(child).call
|
|
19
|
+
child.reload
|
|
20
|
+
rescue => error
|
|
21
|
+
Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
|
|
22
|
+
child.update!(name: fallback_nickname)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fallback_nickname
|
|
26
|
+
"agent-#{@session.child_sessions.count}"
|
|
27
|
+
end
|
|
11
28
|
end
|
|
12
29
|
end
|
data/lib/tools/web_get.rb
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "certifi"
|
|
3
4
|
require "httparty"
|
|
4
5
|
|
|
5
6
|
module Tools
|
|
6
|
-
# Fetches content from a URL via HTTP GET. Returns
|
|
7
|
-
#
|
|
7
|
+
# Fetches content from a URL via HTTP GET. Returns a structured result with
|
|
8
|
+
# the response body and Content-Type header so that {ToolDecorator} can apply
|
|
9
|
+
# format-specific conversion (HTML → Markdown, JSON → TOON, etc.).
|
|
10
|
+
#
|
|
11
|
+
# The body is truncated to {Anima::Settings.max_web_response_bytes} before
|
|
12
|
+
# decoration to cap memory usage on large responses.
|
|
8
13
|
#
|
|
9
14
|
# Only http and https schemes are allowed.
|
|
10
15
|
class WebGet < Base
|
|
@@ -23,8 +28,8 @@ module Tools
|
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
26
|
-
# @return [
|
|
27
|
-
# @return [Hash]
|
|
31
|
+
# @return [Hash] `{body: String, content_type: String}` on success
|
|
32
|
+
# @return [Hash] `{error: String}` on failure
|
|
28
33
|
def execute(input)
|
|
29
34
|
validate_and_fetch(input["url"].to_s)
|
|
30
35
|
end
|
|
@@ -39,7 +44,11 @@ module Tools
|
|
|
39
44
|
return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
|
|
40
45
|
end
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
response = HTTParty.get(url, timeout: timeout, follow_redirects: false, ssl_ca_file: Certifi.where)
|
|
48
|
+
body = truncate_body(response.body.to_s)
|
|
49
|
+
content_type = response.content_type || "text/plain"
|
|
50
|
+
|
|
51
|
+
{body: body, content_type: content_type}
|
|
43
52
|
rescue URI::InvalidURIError => error
|
|
44
53
|
{error: "Invalid URL: #{error.message}"}
|
|
45
54
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
@@ -54,7 +63,7 @@ module Tools
|
|
|
54
63
|
max_bytes = Anima::Settings.max_web_response_bytes
|
|
55
64
|
return body if body.bytesize <= max_bytes
|
|
56
65
|
|
|
57
|
-
body.byteslice(0, max_bytes) +
|
|
66
|
+
body.byteslice(0, max_bytes).scrub +
|
|
58
67
|
"\n\n[Truncated: response exceeded #{max_bytes} bytes]"
|
|
59
68
|
end
|
|
60
69
|
end
|