akaitsume 0.1.0 → 0.1.1
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/README.md +50 -31
- data/bin/akaitsume +1 -0
- data/lib/akaitsume/agent.rb +15 -22
- data/lib/akaitsume/cli.rb +3 -21
- data/lib/akaitsume/config.rb +9 -3
- data/lib/akaitsume/hooks.rb +6 -8
- data/lib/akaitsume/memory/file_store.rb +0 -2
- data/lib/akaitsume/provider/anthropic.rb +5 -3
- data/lib/akaitsume/provider/response.rb +3 -8
- data/lib/akaitsume/session.rb +3 -0
- data/lib/akaitsume/tool/base.rb +3 -2
- data/lib/akaitsume/tool/bash.rb +36 -8
- data/lib/akaitsume/tool/files.rb +4 -4
- data/lib/akaitsume/tool/http.rb +51 -6
- data/lib/akaitsume/tool/memory_tool.rb +8 -13
- data/lib/akaitsume/tool/registry.rb +56 -7
- data/lib/akaitsume/version.rb +1 -1
- data/lib/akaitsume.rb +12 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3fecc18ed7284344542f94ca9cc13d0489e5165a61ddb054c285f30a6b00c39
|
|
4
|
+
data.tar.gz: 553b5efd077b266ea0b9962f007ecf0caae5cc4fc06b51ae2bce7c818ececf5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 264c20941a3dd343fed396843aab2bac073f5759fafa491ba79fbcfdb86c4c4d97669ffb9700767f2aeee421a9e3c11228635fd05147956dcc653b8611c175f0
|
|
7
|
+
data.tar.gz: b569bb26ae8b60a6754b4892e4e56bcb88bfa584529f378c685c619272ede11e2bd6d0beb78c7b7c61c9fb87784daac74a22af68db773544831314cff1e260f0
|
data/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Akaitsume
|
|
2
2
|
|
|
3
|
-
赤い爪 — A sharp, extensible AI agent framework for Ruby
|
|
3
|
+
赤い爪 — A sharp, extensible AI agent framework for Ruby.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Provider abstraction** — swap LLM backends without changing agent code (Anthropic built-in, OpenAI/Ollama ready to add)
|
|
8
|
-
- **Tool system** — trait-based tools with auto-discovery
|
|
8
|
+
- **Tool system** — trait-based tools with auto-discovery and external plugin loading
|
|
9
|
+
- **Security** — dangerous command guard (Bash), SSRF protection (HTTP), path traversal prevention (Files)
|
|
9
10
|
- **Memory backends** — FileStore (default) or SQLite, switchable via config
|
|
10
11
|
- **Session management** — conversation continuity with token/cost tracking
|
|
11
12
|
- **Hooks** — `before_tool`, `after_tool`, `on_response`, `on_error` callbacks
|
|
@@ -28,7 +29,8 @@ gem install akaitsume
|
|
|
28
29
|
## Quick start
|
|
29
30
|
|
|
30
31
|
```sh
|
|
31
|
-
|
|
32
|
+
# Set your API key (or use .env file with dotenv)
|
|
33
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
32
34
|
|
|
33
35
|
# Single prompt
|
|
34
36
|
akaitsume run "list files in the current directory"
|
|
@@ -76,20 +78,22 @@ Config is loaded from YAML, environment variables, or passed directly:
|
|
|
76
78
|
|
|
77
79
|
```yaml
|
|
78
80
|
# config/agent.yml
|
|
79
|
-
model: claude-
|
|
81
|
+
model: claude-haiku-4-5-20251001
|
|
80
82
|
max_turns: 20
|
|
81
83
|
max_tokens: 8096
|
|
82
84
|
workspace: ~/.akaitsume/workspace
|
|
83
|
-
memory_backend: file
|
|
85
|
+
memory_backend: file # or "sqlite"
|
|
84
86
|
db_path: ~/.akaitsume/akaitsume.db
|
|
85
87
|
log_level: info
|
|
88
|
+
tool_paths: # external plugin directories
|
|
89
|
+
- ~/.akaitsume/plugins
|
|
86
90
|
```
|
|
87
91
|
|
|
88
92
|
```sh
|
|
89
93
|
akaitsume run "hello" --config config/agent.yml
|
|
90
94
|
```
|
|
91
95
|
|
|
92
|
-
Environment: `ANTHROPIC_API_KEY` is required.
|
|
96
|
+
Environment: `ANTHROPIC_API_KEY` is required. Use a `.env` file with `dotenv` for convenience.
|
|
93
97
|
|
|
94
98
|
## Architecture
|
|
95
99
|
|
|
@@ -108,49 +112,63 @@ lib/akaitsume/
|
|
|
108
112
|
│
|
|
109
113
|
├── provider/
|
|
110
114
|
│ ├── base.rb # Provider contract (module)
|
|
111
|
-
│ ├── response.rb #
|
|
115
|
+
│ ├── response.rb # Immutable response (Data.define)
|
|
112
116
|
│ └── anthropic.rb # Anthropic SDK wrapper
|
|
113
117
|
│
|
|
114
118
|
├── tool/
|
|
115
119
|
│ ├── base.rb # Tool contract (module)
|
|
116
|
-
│ ├── registry.rb #
|
|
117
|
-
│ ├── bash.rb # Shell execution
|
|
120
|
+
│ ├── registry.rb # Auto-discovery + plugin loading
|
|
121
|
+
│ ├── bash.rb # Shell execution (with dangerous command guard)
|
|
118
122
|
│ ├── files.rb # File operations (with path traversal protection)
|
|
119
|
-
│ ├── http.rb # HTTP requests
|
|
123
|
+
│ ├── http.rb # HTTP requests (with SSRF protection)
|
|
120
124
|
│ └── memory_tool.rb # LLM-facing memory read/write/search
|
|
121
125
|
│
|
|
122
126
|
└── memory/
|
|
123
|
-
├── base.rb # Memory contract (module)
|
|
127
|
+
├── base.rb # Memory contract (module) + Memory.build factory
|
|
124
128
|
├── file_store.rb # Markdown file backend (default)
|
|
125
129
|
└── sqlite_store.rb # SQLite backend (opt-in)
|
|
126
130
|
```
|
|
127
131
|
|
|
128
132
|
## Extending
|
|
129
133
|
|
|
130
|
-
### Custom tool
|
|
134
|
+
### Custom tool (plugin)
|
|
135
|
+
|
|
136
|
+
Drop a `.rb` file in a `tool_paths` directory:
|
|
131
137
|
|
|
132
138
|
```ruby
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
# ~/.akaitsume/plugins/weather.rb
|
|
140
|
+
module Akaitsume
|
|
141
|
+
module Tool
|
|
142
|
+
class Weather
|
|
143
|
+
include Base
|
|
144
|
+
|
|
145
|
+
tool_name 'weather'
|
|
146
|
+
description 'Get current weather for a city'
|
|
147
|
+
input_schema({
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
city: { type: 'string', description: 'City name' }
|
|
151
|
+
},
|
|
152
|
+
required: ['city']
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
def call(input)
|
|
156
|
+
"Weather in #{input['city']}: 22C, sunny"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
149
159
|
end
|
|
150
160
|
end
|
|
161
|
+
```
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
agent
|
|
163
|
+
```yaml
|
|
164
|
+
# config/agent.yml
|
|
165
|
+
tool_paths:
|
|
166
|
+
- ~/.akaitsume/plugins
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Or register manually:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
154
172
|
registry = Akaitsume::Tool::Registry.new
|
|
155
173
|
registry.register(MyTool)
|
|
156
174
|
agent = Akaitsume::Agent.new(tools: registry)
|
|
@@ -168,7 +186,7 @@ class OllamaProvider
|
|
|
168
186
|
# Call Ollama API, return Provider::Response
|
|
169
187
|
Akaitsume::Provider::Response.new(
|
|
170
188
|
content: [...],
|
|
171
|
-
stop_reason:
|
|
189
|
+
stop_reason: :end_turn,
|
|
172
190
|
model: model,
|
|
173
191
|
usage: { input_tokens: 0, output_tokens: 0 }
|
|
174
192
|
)
|
|
@@ -202,6 +220,7 @@ agent = Akaitsume::Agent.new(memory: RedisStore.new)
|
|
|
202
220
|
| `faraday` | HTTP tool |
|
|
203
221
|
| `sqlite3` | SQLite memory backend |
|
|
204
222
|
| `thor` | CLI framework |
|
|
223
|
+
| `dotenv` | `.env` file loading |
|
|
205
224
|
|
|
206
225
|
## License
|
|
207
226
|
|
data/bin/akaitsume
CHANGED
data/lib/akaitsume/agent.rb
CHANGED
|
@@ -10,12 +10,11 @@ module Akaitsume
|
|
|
10
10
|
provider: nil, tools: nil, memory: nil, logger: nil)
|
|
11
11
|
@name = name
|
|
12
12
|
@role = role
|
|
13
|
-
@config = config
|
|
13
|
+
@config = config.ensure_directories!
|
|
14
14
|
@provider = provider || Provider::Anthropic.new(api_key: config.api_key)
|
|
15
|
-
@memory = memory ||
|
|
15
|
+
@memory = memory || Memory.build(config, agent_name: name)
|
|
16
16
|
@tools = tools || Tool::Registry.default_for(config, memory: @memory)
|
|
17
17
|
@logger = logger || Logger.new(level: config.log_level)
|
|
18
|
-
init_hooks
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
# Spawn a sub-agent with its own tools and memory
|
|
@@ -26,7 +25,7 @@ module Akaitsume
|
|
|
26
25
|
config: @config,
|
|
27
26
|
provider: @provider,
|
|
28
27
|
tools: tools,
|
|
29
|
-
memory:
|
|
28
|
+
memory: Memory.build(@config, agent_name: name),
|
|
30
29
|
logger: @logger
|
|
31
30
|
)
|
|
32
31
|
end
|
|
@@ -43,19 +42,17 @@ module Akaitsume
|
|
|
43
42
|
loop do
|
|
44
43
|
raise MaxTurnsError, "Exceeded max_turns (#{@config.max_turns})" if session.turn_count >= @config.max_turns
|
|
45
44
|
|
|
45
|
+
session.increment_turn
|
|
46
46
|
response = call_provider(sys, session)
|
|
47
47
|
session.add_assistant(response.content)
|
|
48
48
|
session.track_usage(response)
|
|
49
49
|
|
|
50
|
-
if response.tool_use?
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
block&.call(text)
|
|
57
|
-
return text
|
|
58
|
-
end
|
|
50
|
+
next dispatch_tool_cycle(response, session) if response.tool_use?
|
|
51
|
+
|
|
52
|
+
text = extract_text(response.content)
|
|
53
|
+
fire(:on_response, text)
|
|
54
|
+
block&.call(text)
|
|
55
|
+
return text
|
|
59
56
|
end
|
|
60
57
|
rescue StandardError => e
|
|
61
58
|
fire(:on_error, e)
|
|
@@ -64,13 +61,9 @@ module Akaitsume
|
|
|
64
61
|
|
|
65
62
|
private
|
|
66
63
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Memory::SqliteStore.new(db_path: config.db_path, agent_name: agent_name)
|
|
71
|
-
else
|
|
72
|
-
Memory::FileStore.new(dir: config.memory_dir, agent_name: agent_name)
|
|
73
|
-
end
|
|
64
|
+
def dispatch_tool_cycle(response, session)
|
|
65
|
+
tool_results = dispatch_tools(response.content)
|
|
66
|
+
session.add_tool_results(tool_results)
|
|
74
67
|
end
|
|
75
68
|
|
|
76
69
|
def inject_memory_and_prompt(session, prompt)
|
|
@@ -110,7 +103,7 @@ module Akaitsume
|
|
|
110
103
|
|
|
111
104
|
def dispatch_tools(content_blocks)
|
|
112
105
|
content_blocks.filter_map do |block|
|
|
113
|
-
next unless block.type ==
|
|
106
|
+
next unless block.type == :tool_use
|
|
114
107
|
|
|
115
108
|
tool = @tools[block.name]
|
|
116
109
|
|
|
@@ -134,7 +127,7 @@ module Akaitsume
|
|
|
134
127
|
|
|
135
128
|
def extract_text(content_blocks)
|
|
136
129
|
content_blocks
|
|
137
|
-
.select { |b| b.type ==
|
|
130
|
+
.select { |b| b.type == :text }
|
|
138
131
|
.map(&:text)
|
|
139
132
|
.join
|
|
140
133
|
end
|
data/lib/akaitsume/cli.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Akaitsume
|
|
|
47
47
|
desc 'tools', 'List registered tools'
|
|
48
48
|
def tools
|
|
49
49
|
cfg = load_config
|
|
50
|
-
memory =
|
|
50
|
+
memory = Memory.build(cfg)
|
|
51
51
|
registry = Tool::Registry.default_for(cfg, memory: memory)
|
|
52
52
|
|
|
53
53
|
registry.names.each { |n| say " \u2022 #{n}" }
|
|
@@ -60,14 +60,14 @@ module Akaitsume
|
|
|
60
60
|
desc 'show [AGENT]', 'Show agent memory'
|
|
61
61
|
def show(agent_name = 'akaitsume')
|
|
62
62
|
cfg = parent_load_config
|
|
63
|
-
store =
|
|
63
|
+
store = Memory.build(cfg, agent_name: agent_name)
|
|
64
64
|
say store.read || '(empty)'
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
desc 'search QUERY [AGENT]', 'Search agent memory'
|
|
68
68
|
def search(query, agent_name = 'akaitsume')
|
|
69
69
|
cfg = parent_load_config
|
|
70
|
-
store =
|
|
70
|
+
store = Memory.build(cfg, agent_name: agent_name)
|
|
71
71
|
say store.search(query)
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -76,15 +76,6 @@ module Akaitsume
|
|
|
76
76
|
path = parent_options[:config]
|
|
77
77
|
path ? Config.load(path: path) : Config.load
|
|
78
78
|
end
|
|
79
|
-
|
|
80
|
-
def parent_build_memory(cfg, agent_name = 'akaitsume')
|
|
81
|
-
case cfg.memory_backend
|
|
82
|
-
when 'sqlite'
|
|
83
|
-
Memory::SqliteStore.new(db_path: cfg.db_path, agent_name: agent_name)
|
|
84
|
-
else
|
|
85
|
-
Memory::FileStore.new(dir: cfg.memory_dir, agent_name: agent_name)
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
79
|
end
|
|
89
80
|
}
|
|
90
81
|
|
|
@@ -101,15 +92,6 @@ module Akaitsume
|
|
|
101
92
|
end
|
|
102
93
|
end
|
|
103
94
|
|
|
104
|
-
def build_memory(cfg, agent_name = 'akaitsume')
|
|
105
|
-
case cfg.memory_backend
|
|
106
|
-
when 'sqlite'
|
|
107
|
-
Memory::SqliteStore.new(db_path: cfg.db_path, agent_name: agent_name)
|
|
108
|
-
else
|
|
109
|
-
Memory::FileStore.new(dir: cfg.memory_dir, agent_name: agent_name)
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
95
|
def build_agent
|
|
114
96
|
cfg = load_config
|
|
115
97
|
Agent.new(config: cfg)
|
data/lib/akaitsume/config.rb
CHANGED
|
@@ -6,18 +6,20 @@ require 'fileutils'
|
|
|
6
6
|
module Akaitsume
|
|
7
7
|
class Config
|
|
8
8
|
DEFAULTS = {
|
|
9
|
-
model: 'claude-
|
|
9
|
+
model: 'claude-haiku-4-5-20251001',
|
|
10
10
|
max_turns: 20,
|
|
11
11
|
max_tokens: 8096,
|
|
12
12
|
workspace: Dir.home + '/.akaitsume/workspace',
|
|
13
13
|
memory_dir: Dir.home + '/.akaitsume/memory',
|
|
14
14
|
memory_backend: 'file',
|
|
15
15
|
db_path: Dir.home + '/.akaitsume/akaitsume.db',
|
|
16
|
-
log_level: 'info'
|
|
16
|
+
log_level: 'info',
|
|
17
|
+
tool_paths: []
|
|
17
18
|
}.freeze
|
|
18
19
|
|
|
19
20
|
attr_reader :model, :max_turns, :max_tokens, :workspace,
|
|
20
|
-
:memory_dir, :memory_backend, :db_path, :log_level, :api_key
|
|
21
|
+
:memory_dir, :memory_backend, :db_path, :log_level, :api_key,
|
|
22
|
+
:tool_paths
|
|
21
23
|
|
|
22
24
|
def self.load(path: nil)
|
|
23
25
|
file_cfg = path ? YAML.safe_load_file(path, symbolize_names: true) : {}
|
|
@@ -35,9 +37,13 @@ module Akaitsume
|
|
|
35
37
|
@memory_backend = cfg[:memory_backend].to_s
|
|
36
38
|
@db_path = cfg[:db_path]
|
|
37
39
|
@log_level = cfg[:log_level]
|
|
40
|
+
@tool_paths = Array(cfg[:tool_paths])
|
|
41
|
+
end
|
|
38
42
|
|
|
43
|
+
def ensure_directories!
|
|
39
44
|
FileUtils.mkdir_p(@workspace)
|
|
40
45
|
FileUtils.mkdir_p(@memory_dir)
|
|
46
|
+
self
|
|
41
47
|
end
|
|
42
48
|
end
|
|
43
49
|
end
|
data/lib/akaitsume/hooks.rb
CHANGED
|
@@ -4,22 +4,20 @@ module Akaitsume
|
|
|
4
4
|
module Hooks
|
|
5
5
|
EVENTS = %i[before_tool after_tool on_response on_error].freeze
|
|
6
6
|
|
|
7
|
-
def self.included(base)
|
|
8
|
-
base.define_method(:init_hooks) do
|
|
9
|
-
@hooks = EVENTS.each_with_object({}) { |e, h| h[e] = [] }
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
7
|
EVENTS.each do |event|
|
|
14
8
|
define_method(event) do |&block|
|
|
15
|
-
|
|
9
|
+
hooks[event] << block
|
|
16
10
|
end
|
|
17
11
|
end
|
|
18
12
|
|
|
19
13
|
private
|
|
20
14
|
|
|
15
|
+
def hooks
|
|
16
|
+
@hooks ||= EVENTS.to_h { |e| [e, []] }
|
|
17
|
+
end
|
|
18
|
+
|
|
21
19
|
def fire(event, *args)
|
|
22
|
-
|
|
20
|
+
hooks[event].each { |h| h.call(*args) }
|
|
23
21
|
end
|
|
24
22
|
end
|
|
25
23
|
end
|
|
@@ -12,13 +12,15 @@ module Akaitsume
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def chat(messages:, system:, tools:, model:, max_tokens:)
|
|
15
|
-
|
|
15
|
+
params = {
|
|
16
16
|
model: model,
|
|
17
17
|
max_tokens: max_tokens,
|
|
18
18
|
system: system,
|
|
19
|
-
tools: tools,
|
|
20
19
|
messages: messages
|
|
21
|
-
|
|
20
|
+
}
|
|
21
|
+
params[:tools] = tools if tools && !tools.empty?
|
|
22
|
+
|
|
23
|
+
raw = @client.messages.create(**params)
|
|
22
24
|
|
|
23
25
|
Response.new(
|
|
24
26
|
content: raw.content,
|
|
@@ -2,18 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Akaitsume
|
|
4
4
|
module Provider
|
|
5
|
-
|
|
6
|
-
attr_reader :content, :stop_reason, :model, :usage
|
|
7
|
-
|
|
5
|
+
Response = Data.define(:content, :stop_reason, :model, :usage) do
|
|
8
6
|
def initialize(content:, stop_reason:, model:, usage: {})
|
|
9
|
-
|
|
10
|
-
@stop_reason = stop_reason
|
|
11
|
-
@model = model
|
|
12
|
-
@usage = usage
|
|
7
|
+
super(content: content, stop_reason: stop_reason, model: model, usage: usage)
|
|
13
8
|
end
|
|
14
9
|
|
|
15
10
|
def tool_use?
|
|
16
|
-
stop_reason ==
|
|
11
|
+
stop_reason == :tool_use
|
|
17
12
|
end
|
|
18
13
|
|
|
19
14
|
def input_tokens
|
data/lib/akaitsume/session.rb
CHANGED
data/lib/akaitsume/tool/base.rb
CHANGED
|
@@ -38,9 +38,10 @@ module Akaitsume
|
|
|
38
38
|
raise NotImplementedError, "#{self.class}#call not implemented"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
#
|
|
41
|
+
# Normalizes input keys to strings and wraps result into Anthropic tool_result content format
|
|
42
42
|
def execute(input)
|
|
43
|
-
|
|
43
|
+
normalized = input.is_a?(Hash) ? input.transform_keys(&:to_s) : input
|
|
44
|
+
result = call(normalized)
|
|
44
45
|
{ type: 'text', text: result.to_s }
|
|
45
46
|
rescue StandardError => e
|
|
46
47
|
{ type: 'text', text: "Error: #{e.message}" }
|
data/lib/akaitsume/tool/bash.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
4
5
|
|
|
5
6
|
module Akaitsume
|
|
6
7
|
module Tool
|
|
@@ -28,28 +29,55 @@ module Akaitsume
|
|
|
28
29
|
required: ['command']
|
|
29
30
|
})
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
DANGEROUS_PATTERNS = [
|
|
33
|
+
%r{\brm\s+-rf\s+[/~]}, # rm -rf / or ~
|
|
34
|
+
/\bmkfs\b/,
|
|
35
|
+
%r{\bdd\s+.*of=/dev/},
|
|
36
|
+
%r{>\s*/dev/sd},
|
|
37
|
+
/\bshutdown\b/,
|
|
38
|
+
/\breboot\b/,
|
|
39
|
+
/\bchmod\s+-R\s+777/,
|
|
40
|
+
/\bcurl\b.*\|\s*\bsh\b/, # curl | sh
|
|
41
|
+
/\bwget\b.*\|\s*\bsh\b/ # wget | sh
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
def initialize(workspace:, blocked_commands: nil)
|
|
32
45
|
@workspace = workspace
|
|
46
|
+
@blocked_commands = blocked_commands || DANGEROUS_PATTERNS
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
def call(input)
|
|
36
|
-
cmd = input['command']
|
|
37
|
-
timeout = (input['timeout'] ||
|
|
50
|
+
cmd = input['command']
|
|
51
|
+
timeout = (input['timeout'] || 30).to_i
|
|
52
|
+
|
|
53
|
+
if (violation = detect_dangerous(cmd))
|
|
54
|
+
return "BLOCKED: dangerous command pattern detected (#{violation}). " \
|
|
55
|
+
'If this is intentional, use the files tool or modify blocked_commands config.'
|
|
56
|
+
end
|
|
38
57
|
|
|
39
|
-
stdout, stderr, status =
|
|
40
|
-
cmd,
|
|
41
|
-
|
|
42
|
-
timeout: timeout
|
|
43
|
-
)
|
|
58
|
+
stdout, stderr, status = Timeout.timeout(timeout) do
|
|
59
|
+
Open3.capture3(cmd, chdir: @workspace)
|
|
60
|
+
end
|
|
44
61
|
|
|
45
62
|
parts = []
|
|
46
63
|
parts << stdout.strip unless stdout.strip.empty?
|
|
47
64
|
parts << "[stderr] #{stderr.strip}" unless stderr.strip.empty?
|
|
48
65
|
parts << "[exit #{status.exitstatus}]" unless status.success?
|
|
49
66
|
parts.empty? ? '(no output)' : parts.join("\n")
|
|
67
|
+
rescue Timeout::Error
|
|
68
|
+
"Error: command timed out after #{(input['timeout'] || 30).to_i}s"
|
|
50
69
|
rescue Errno::ENOENT => e
|
|
51
70
|
"Error: #{e.message}"
|
|
52
71
|
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def detect_dangerous(cmd)
|
|
76
|
+
@blocked_commands.each do |pattern|
|
|
77
|
+
return pattern.source if pattern.match?(cmd)
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
53
81
|
end
|
|
54
82
|
end
|
|
55
83
|
end
|
data/lib/akaitsume/tool/files.rb
CHANGED
|
@@ -38,10 +38,10 @@ module Akaitsume
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def call(input)
|
|
41
|
-
action = input['action']
|
|
42
|
-
path = input['path']
|
|
43
|
-
content = input['content']
|
|
44
|
-
pattern = input['pattern'] ||
|
|
41
|
+
action = input['action']
|
|
42
|
+
path = input['path']
|
|
43
|
+
content = input['content']
|
|
44
|
+
pattern = input['pattern'] || '**/*'
|
|
45
45
|
|
|
46
46
|
case action
|
|
47
47
|
when 'read' then read(path)
|
data/lib/akaitsume/tool/http.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'resolv'
|
|
6
|
+
require 'uri'
|
|
5
7
|
|
|
6
8
|
module Akaitsume
|
|
7
9
|
module Tool
|
|
@@ -10,7 +12,8 @@ module Akaitsume
|
|
|
10
12
|
|
|
11
13
|
tool_name 'http'
|
|
12
14
|
description 'Make HTTP requests. Supports GET, POST, PUT, PATCH, DELETE. ' \
|
|
13
|
-
'Returns status code, response headers, and body (truncated to 4KB).'
|
|
15
|
+
'Returns status code, response headers, and body (truncated to 4KB). ' \
|
|
16
|
+
'Requests to private/internal networks are blocked by default.'
|
|
14
17
|
|
|
15
18
|
input_schema({
|
|
16
19
|
type: 'object',
|
|
@@ -43,12 +46,31 @@ module Akaitsume
|
|
|
43
46
|
|
|
44
47
|
MAX_BODY = 4096
|
|
45
48
|
|
|
49
|
+
# CIDR ranges that are blocked by default (private/link-local/loopback)
|
|
50
|
+
BLOCKED_RANGES = [
|
|
51
|
+
IPAddr.new('127.0.0.0/8'), # loopback
|
|
52
|
+
IPAddr.new('10.0.0.0/8'), # private class A
|
|
53
|
+
IPAddr.new('172.16.0.0/12'), # private class B
|
|
54
|
+
IPAddr.new('192.168.0.0/16'), # private class C
|
|
55
|
+
IPAddr.new('169.254.0.0/16'), # link-local / cloud metadata
|
|
56
|
+
IPAddr.new('0.0.0.0/8'), # "this" network
|
|
57
|
+
IPAddr.new('::1/128'), # IPv6 loopback
|
|
58
|
+
IPAddr.new('fc00::/7'), # IPv6 unique local
|
|
59
|
+
IPAddr.new('fe80::/10') # IPv6 link-local
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
def initialize(blocked_ranges: nil)
|
|
63
|
+
@blocked_ranges = blocked_ranges || BLOCKED_RANGES
|
|
64
|
+
end
|
|
65
|
+
|
|
46
66
|
def call(input)
|
|
47
|
-
method =
|
|
48
|
-
url = input['url']
|
|
49
|
-
headers = input['headers'] ||
|
|
50
|
-
body = input['body']
|
|
51
|
-
timeout = (input['timeout'] ||
|
|
67
|
+
method = input['method'].downcase.to_sym
|
|
68
|
+
url = input['url']
|
|
69
|
+
headers = input['headers'] || {}
|
|
70
|
+
body = input['body']
|
|
71
|
+
timeout = (input['timeout'] || 30).to_i
|
|
72
|
+
|
|
73
|
+
validate_url!(url)
|
|
52
74
|
|
|
53
75
|
conn = Faraday.new do |f|
|
|
54
76
|
f.options.timeout = timeout
|
|
@@ -68,6 +90,29 @@ module Akaitsume
|
|
|
68
90
|
rescue Faraday::Error => e
|
|
69
91
|
"Error: #{e.class} - #{e.message}"
|
|
70
92
|
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def validate_url!(url)
|
|
97
|
+
uri = URI.parse(url)
|
|
98
|
+
host = uri.host
|
|
99
|
+
|
|
100
|
+
raise 'BLOCKED: invalid URL' unless host
|
|
101
|
+
raise 'BLOCKED: only http/https allowed' unless %w[http https].include?(uri.scheme)
|
|
102
|
+
|
|
103
|
+
# Resolve hostname to IP and check against blocked ranges
|
|
104
|
+
ips = Resolv.getaddresses(host)
|
|
105
|
+
raise "BLOCKED: cannot resolve host '#{host}'" if ips.empty?
|
|
106
|
+
|
|
107
|
+
ips.each do |ip_str|
|
|
108
|
+
ip = IPAddr.new(ip_str)
|
|
109
|
+
if @blocked_ranges.any? { |range| range.include?(ip) }
|
|
110
|
+
raise "BLOCKED: requests to private/internal networks are not allowed (#{host} -> #{ip_str})"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
rescue URI::InvalidURIError
|
|
114
|
+
raise 'BLOCKED: malformed URL'
|
|
115
|
+
end
|
|
71
116
|
end
|
|
72
117
|
end
|
|
73
118
|
end
|
|
@@ -36,30 +36,25 @@ module Akaitsume
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def call(input)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
case action
|
|
39
|
+
case input['action']
|
|
42
40
|
when 'read'
|
|
43
41
|
@memory.read || '(empty memory)'
|
|
44
42
|
when 'store'
|
|
45
|
-
content
|
|
46
|
-
return 'Error: content is required for store' unless content
|
|
43
|
+
return 'Error: content is required for store' unless input['content']
|
|
47
44
|
|
|
48
|
-
@memory.store(content)
|
|
45
|
+
@memory.store(input['content'])
|
|
49
46
|
'Stored to memory.'
|
|
50
47
|
when 'search'
|
|
51
|
-
query
|
|
52
|
-
return 'Error: query is required for search' unless query
|
|
48
|
+
return 'Error: query is required for search' unless input['query']
|
|
53
49
|
|
|
54
|
-
@memory.search(query)
|
|
50
|
+
@memory.search(input['query'])
|
|
55
51
|
when 'replace'
|
|
56
|
-
content
|
|
57
|
-
return 'Error: content is required for replace' unless content
|
|
52
|
+
return 'Error: content is required for replace' unless input['content']
|
|
58
53
|
|
|
59
|
-
@memory.replace(content)
|
|
54
|
+
@memory.replace(input['content'])
|
|
60
55
|
'Memory replaced.'
|
|
61
56
|
else
|
|
62
|
-
"Error: unknown action '#{action}'"
|
|
57
|
+
"Error: unknown action '#{input['action']}'"
|
|
63
58
|
end
|
|
64
59
|
end
|
|
65
60
|
end
|
|
@@ -15,7 +15,7 @@ module Akaitsume
|
|
|
15
15
|
|
|
16
16
|
def [](name)
|
|
17
17
|
entry = @tools[name] || raise(ToolNotFoundError, "Tool '#{name}' not registered")
|
|
18
|
-
entry[:klass].new(**entry[:init_args])
|
|
18
|
+
entry[:instance] ||= entry[:klass].new(**entry[:init_args])
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def api_definitions
|
|
@@ -26,16 +26,65 @@ module Akaitsume
|
|
|
26
26
|
@tools.keys
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
29
|
+
# Auto-discover and register all built-in Tool classes.
|
|
30
|
+
# Resolves constructor args from the provided context.
|
|
31
31
|
def self.default_for(config, memory: nil)
|
|
32
|
+
context = { workspace: config.workspace, memory: memory }
|
|
33
|
+
|
|
32
34
|
new.tap do |r|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
discover_tools.each do |klass|
|
|
36
|
+
args = resolve_args(klass, context)
|
|
37
|
+
next if args.nil? # skip if required args not available
|
|
38
|
+
|
|
39
|
+
r.register(klass, **args)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load external tools from config paths
|
|
43
|
+
load_external(config.tool_paths).each do |klass|
|
|
44
|
+
args = resolve_args(klass, context) || {}
|
|
45
|
+
r.register(klass, **args)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Find all classes in Akaitsume::Tool that include Tool::Base
|
|
51
|
+
def self.discover_tools
|
|
52
|
+
Tool.constants
|
|
53
|
+
.map { |name| Tool.const_get(name) }
|
|
54
|
+
.select { |klass| klass.is_a?(Class) && klass < Base }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Match a tool's initialize params to the available context
|
|
58
|
+
def self.resolve_args(klass, context)
|
|
59
|
+
params = klass.instance_method(:initialize).parameters
|
|
60
|
+
return {} if params.empty?
|
|
61
|
+
|
|
62
|
+
args = {}
|
|
63
|
+
params.each do |type, name|
|
|
64
|
+
if context.key?(name)
|
|
65
|
+
args[name] = context[name]
|
|
66
|
+
elsif %i[keyreq req].include?(type)
|
|
67
|
+
return nil # required arg missing — skip this tool
|
|
68
|
+
end
|
|
37
69
|
end
|
|
70
|
+
args
|
|
38
71
|
end
|
|
72
|
+
|
|
73
|
+
# Load .rb files from external paths, return tool classes defined in them
|
|
74
|
+
def self.load_external(paths)
|
|
75
|
+
return [] if paths.empty?
|
|
76
|
+
|
|
77
|
+
before = Tool.constants.dup
|
|
78
|
+
paths.each do |path|
|
|
79
|
+
Dir.glob(File.join(path, '*.rb')).each { |f| require f }
|
|
80
|
+
end
|
|
81
|
+
new_constants = Tool.constants - before
|
|
82
|
+
new_constants
|
|
83
|
+
.map { |name| Tool.const_get(name) }
|
|
84
|
+
.select { |klass| klass.is_a?(Class) && klass < Base }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method :discover_tools, :resolve_args, :load_external
|
|
39
88
|
end
|
|
40
89
|
end
|
|
41
90
|
end
|
data/lib/akaitsume/version.rb
CHANGED
data/lib/akaitsume.rb
CHANGED
|
@@ -12,4 +12,16 @@ module Akaitsume
|
|
|
12
12
|
class MaxTurnsError < Error; end
|
|
13
13
|
class ToolNotFoundError < Error; end
|
|
14
14
|
class ConfigError < Error; end
|
|
15
|
+
|
|
16
|
+
module Memory
|
|
17
|
+
# Factory: builds a memory store based on config
|
|
18
|
+
def self.build(config, agent_name: 'akaitsume')
|
|
19
|
+
case config.memory_backend
|
|
20
|
+
when 'sqlite'
|
|
21
|
+
SqliteStore.new(db_path: config.db_path, agent_name: agent_name)
|
|
22
|
+
else
|
|
23
|
+
FileStore.new(dir: config.memory_dir, agent_name: agent_name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
15
27
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: akaitsume
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mateusz Palak
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: dotenv
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: faraday
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -167,7 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
167
181
|
- !ruby/object:Gem::Version
|
|
168
182
|
version: '0'
|
|
169
183
|
requirements: []
|
|
170
|
-
rubygems_version:
|
|
184
|
+
rubygems_version: 4.0.9
|
|
171
185
|
specification_version: 4
|
|
172
186
|
summary: 赤い爪 — A sharp, extensible AI agent for Ruby
|
|
173
187
|
test_files: []
|