ollama_agent 0.3.0 → 1.0.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +14 -3
  4. data/lib/ollama_agent/agent/agent_config.rb +19 -2
  5. data/lib/ollama_agent/agent/client_wiring.rb +3 -8
  6. data/lib/ollama_agent/agent/session_wiring.rb +37 -3
  7. data/lib/ollama_agent/agent.rb +82 -6
  8. data/lib/ollama_agent/cli/repl.rb +159 -0
  9. data/lib/ollama_agent/cli/repl_shared.rb +229 -0
  10. data/lib/ollama_agent/cli/tui_repl.rb +149 -0
  11. data/lib/ollama_agent/cli.rb +129 -49
  12. data/lib/ollama_agent/core/action_envelope.rb +82 -0
  13. data/lib/ollama_agent/core/budget.rb +90 -0
  14. data/lib/ollama_agent/core/loop_detector.rb +67 -0
  15. data/lib/ollama_agent/core/schema_validator.rb +136 -0
  16. data/lib/ollama_agent/core/trace_logger.rb +138 -0
  17. data/lib/ollama_agent/external_agents/probe.rb +23 -3
  18. data/lib/ollama_agent/indexing/context_packer.rb +140 -0
  19. data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
  20. data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
  21. data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
  22. data/lib/ollama_agent/memory/long_term.rb +109 -0
  23. data/lib/ollama_agent/memory/manager.rb +121 -0
  24. data/lib/ollama_agent/memory/session_memory.rb +93 -0
  25. data/lib/ollama_agent/memory/short_term.rb +66 -0
  26. data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
  27. data/lib/ollama_agent/ollama_connection.rb +30 -0
  28. data/lib/ollama_agent/plugins/loader.rb +95 -0
  29. data/lib/ollama_agent/plugins/registry.rb +103 -0
  30. data/lib/ollama_agent/providers/anthropic.rb +245 -0
  31. data/lib/ollama_agent/providers/base.rb +79 -0
  32. data/lib/ollama_agent/providers/ollama.rb +118 -0
  33. data/lib/ollama_agent/providers/openai.rb +215 -0
  34. data/lib/ollama_agent/providers/registry.rb +76 -0
  35. data/lib/ollama_agent/providers/router.rb +93 -0
  36. data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
  37. data/lib/ollama_agent/runner.rb +25 -4
  38. data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
  39. data/lib/ollama_agent/runtime/permissions.rb +103 -0
  40. data/lib/ollama_agent/runtime/policies.rb +100 -0
  41. data/lib/ollama_agent/runtime/sandbox.rb +130 -0
  42. data/lib/ollama_agent/streaming/hooks.rb +3 -1
  43. data/lib/ollama_agent/tools/base.rb +108 -0
  44. data/lib/ollama_agent/tools/git_tools.rb +176 -0
  45. data/lib/ollama_agent/tools/http_tools.rb +202 -0
  46. data/lib/ollama_agent/tools/memory_tools.rb +116 -0
  47. data/lib/ollama_agent/tools/shell_tools.rb +208 -0
  48. data/lib/ollama_agent/tui.rb +183 -0
  49. data/lib/ollama_agent/tui_slash_reader.rb +147 -0
  50. data/lib/ollama_agent/tui_user_prompt.rb +45 -0
  51. data/lib/ollama_agent/version.rb +1 -1
  52. data/lib/ollama_agent.rb +46 -1
  53. metadata +142 -5
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Tools
5
+ # Base class for all typed, permissioned, auditable tools.
6
+ #
7
+ # Subclasses must implement #call(args, context:) and define:
8
+ # - name String
9
+ # - description String
10
+ # - input_schema Hash (JSON schema for arguments)
11
+ #
12
+ # @example
13
+ # class MyTool < OllamaAgent::Tools::Base
14
+ # tool_name "my_tool"
15
+ # tool_description "Does something useful"
16
+ # tool_risk :low
17
+ # tool_schema { type: "object", properties: { ... }, required: [...] }
18
+ #
19
+ # def call(args, context:)
20
+ # { result: args["input"].upcase }
21
+ # end
22
+ # end
23
+ class Base
24
+ RISK_LEVELS = %i[low medium high critical].freeze
25
+
26
+ class << self
27
+ def tool_name(name = nil)
28
+ @tool_name = name.to_s if name
29
+ @tool_name || raise(NotImplementedError, "#{self}.tool_name not set")
30
+ end
31
+
32
+ def tool_description(desc = nil)
33
+ @tool_description = desc if desc
34
+ @tool_description || ""
35
+ end
36
+
37
+ def tool_risk(level = nil)
38
+ @tool_risk = level.to_sym if level
39
+ @tool_risk || :low
40
+ end
41
+
42
+ def tool_requires_approval(flag = nil)
43
+ @tool_requires_approval = flag unless flag.nil?
44
+ @tool_requires_approval.nil? ? (@tool_risk || :low) == :high || (@tool_risk || :low) == :critical : @tool_requires_approval
45
+ end
46
+
47
+ def tool_schema(schema = nil)
48
+ @tool_schema = schema if schema
49
+ @tool_schema || { type: "object", properties: {}, required: [] }
50
+ end
51
+
52
+ def tool_output_schema(schema = nil)
53
+ @tool_output_schema = schema if schema
54
+ @tool_output_schema
55
+ end
56
+
57
+ def inherited(subclass)
58
+ super
59
+ # subclass inherits nil overrides so defaults still apply
60
+ end
61
+ end
62
+
63
+ attr_reader :name, :description, :input_schema, :output_schema, :risk_level, :requires_approval
64
+
65
+ def initialize
66
+ @name = self.class.tool_name
67
+ @description = self.class.tool_description
68
+ @input_schema = self.class.tool_schema
69
+ @output_schema = self.class.tool_output_schema
70
+ @risk_level = self.class.tool_risk
71
+ @requires_approval = self.class.tool_requires_approval
72
+ end
73
+
74
+ # Execute the tool.
75
+ # @param args [Hash] validated argument hash
76
+ # @param context [Hash] runtime context: { root:, read_only:, run_id:, … }
77
+ # @return [String, Hash] result surfaced back to the model
78
+ def call(args, context: {})
79
+ raise NotImplementedError, "#{self.class}#call not implemented"
80
+ end
81
+
82
+ # JSON schema formatted for Ollama / OpenAI tool_call
83
+ def to_ollama_schema
84
+ {
85
+ type: "function",
86
+ function: {
87
+ name: @name,
88
+ description: @description,
89
+ parameters: @input_schema
90
+ }
91
+ }
92
+ end
93
+
94
+ # Anthropic-format tool definition
95
+ def to_anthropic_schema
96
+ {
97
+ name: @name,
98
+ description: @description,
99
+ input_schema: @input_schema
100
+ }
101
+ end
102
+
103
+ def to_s
104
+ "#<Tool:#{@name} risk=#{@risk_level} approval=#{@requires_approval}>"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base"
6
+
7
+ module OllamaAgent
8
+ module Tools
9
+ # Shared helper for git-* tools (instance-level `git_run`, not a module function).
10
+ class GitBase < Base
11
+ private
12
+
13
+ def git_run(cmd, cwd:)
14
+ stdout, stderr, _status = Open3.capture3(cmd, chdir: cwd)
15
+ out = stdout.strip
16
+ err = stderr.strip
17
+ result = [out, (err.empty? ? nil : "stderr: #{err}")].compact.join("\n")
18
+ result.empty? ? "(no output)" : result
19
+ rescue Errno::ENOENT
20
+ "Error: git not found on PATH"
21
+ end
22
+ end
23
+
24
+ # Git status — read-only, no approval needed
25
+ class GitStatus < GitBase
26
+ tool_name "git_status"
27
+ tool_description "Show the working tree status (staged, unstaged, untracked files)"
28
+ tool_risk :low
29
+ tool_requires_approval false
30
+ tool_schema({
31
+ type: "object",
32
+ properties: {
33
+ short: { type: "boolean", description: "Use short format (default: false)" }
34
+ },
35
+ required: []
36
+ })
37
+
38
+ def call(args, context: {})
39
+ root = context[:root] || Dir.pwd
40
+ short = args["short"] ? "--short" : "--porcelain=v1"
41
+ git_run("git status #{short}", cwd: root)
42
+ end
43
+ end
44
+
45
+ # Git diff — read-only
46
+ class GitDiff < GitBase
47
+ tool_name "git_diff"
48
+ tool_description "Show changes between commits, working tree, or index"
49
+ tool_risk :low
50
+ tool_requires_approval false
51
+ tool_schema({
52
+ type: "object",
53
+ properties: {
54
+ ref: { type: "string", description: "Commit, branch, or tag to diff against (default: HEAD)" },
55
+ cached: { type: "boolean", description: "Show staged changes (--cached)" },
56
+ path: { type: "string", description: "Limit diff to this path" }
57
+ },
58
+ required: []
59
+ })
60
+
61
+ MAX_DIFF_BYTES = 32_768
62
+
63
+ def call(args, context: {})
64
+ root = context[:root] || Dir.pwd
65
+ ref = args["ref"]
66
+ cached = args["cached"] ? "--cached" : ""
67
+ path = args["path"]
68
+
69
+ cmd_parts = ["git diff", cached, ref, "--", path].compact.reject(&:empty?)
70
+ output = git_run(cmd_parts.join(" "), cwd: root)
71
+ output.byteslice(0, MAX_DIFF_BYTES).then { |o| output.bytesize > MAX_DIFF_BYTES ? "#{o}\n...[truncated]" : o }
72
+ end
73
+ end
74
+
75
+ # Git log — read-only
76
+ class GitLog < GitBase
77
+ tool_name "git_log"
78
+ tool_description "Show commit history"
79
+ tool_risk :low
80
+ tool_requires_approval false
81
+ tool_schema({
82
+ type: "object",
83
+ properties: {
84
+ n: { type: "integer", description: "Number of commits (default 10)", minimum: 1, maximum: 100 },
85
+ oneline: { type: "boolean", description: "One-line format" },
86
+ author: { type: "string", description: "Filter by author" },
87
+ path: { type: "string", description: "Limit to commits touching this path" }
88
+ },
89
+ required: []
90
+ })
91
+
92
+ def call(args, context: {})
93
+ root = context[:root] || Dir.pwd
94
+ n = [args["n"]&.to_i || 10, 100].min
95
+ format = args["oneline"] ? "--oneline" : "--pretty=format:%h %s (%an, %ar)"
96
+ author = args["author"] ? "--author=#{Shellwords.shellescape(args["author"])}" : ""
97
+ path = args["path"]
98
+
99
+ cmd_parts = ["git log", "-n #{n}", format, author, "--", path].compact.reject(&:empty?)
100
+ git_run(cmd_parts.join(" "), cwd: root)
101
+ end
102
+ end
103
+
104
+ # Git commit — requires approval
105
+ class GitCommit < GitBase
106
+ tool_name "git_commit"
107
+ tool_description "Stage specified files and create a git commit"
108
+ tool_risk :medium
109
+ tool_requires_approval true
110
+ tool_schema({
111
+ type: "object",
112
+ properties: {
113
+ message: { type: "string", description: "Commit message", minLength: 3 },
114
+ files: {
115
+ type: "array",
116
+ items: { type: "string" },
117
+ description: "Files to stage (use ['.'] for all changed files — use carefully)"
118
+ },
119
+ all: { type: "boolean", description: "Stage all tracked changes (-a flag)" }
120
+ },
121
+ required: ["message"]
122
+ })
123
+
124
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- staging branches + commit
125
+ def call(args, context: {})
126
+ return "git_commit is disabled in read-only mode" if context[:read_only]
127
+
128
+ root = context[:root] || Dir.pwd
129
+ message = args["message"].to_s.strip
130
+ return "Error: commit message is required" if message.empty?
131
+
132
+ files = Array(args["files"])
133
+ all = args["all"]
134
+
135
+ # Stage files
136
+ if all
137
+ git_run("git add -u", cwd: root)
138
+ elsif files.any?
139
+ safe_files = files.map { |f| Shellwords.shellescape(f) }.join(" ")
140
+ git_run("git add #{safe_files}", cwd: root)
141
+ end
142
+
143
+ git_run("git commit -m #{Shellwords.shellescape(message)}", cwd: root)
144
+ end
145
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
146
+ end
147
+
148
+ # Git branch list — read-only
149
+ class GitBranch < GitBase
150
+ tool_name "git_branch"
151
+ tool_description "List branches or show current branch"
152
+ tool_risk :low
153
+ tool_requires_approval false
154
+ tool_schema({
155
+ type: "object",
156
+ properties: {
157
+ all: { type: "boolean", description: "Include remote branches" },
158
+ current: { type: "boolean", description: "Show current branch name only" }
159
+ },
160
+ required: []
161
+ })
162
+
163
+ def call(args, context: {})
164
+ root = context[:root] || Dir.pwd
165
+
166
+ if args["current"]
167
+ git_run("git rev-parse --abbrev-ref HEAD", cwd: root)
168
+ elsif args["all"]
169
+ git_run("git branch -a", cwd: root)
170
+ else
171
+ git_run("git branch", cwd: root)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "base"
7
+
8
+ module OllamaAgent
9
+ module Tools
10
+ # HTTP GET tool — for fetching documentation, APIs, public resources.
11
+ class HttpGet < Base
12
+ tool_name "http_get"
13
+ tool_description "Fetch a URL via HTTP GET and return the response body (text/JSON only)"
14
+ tool_risk :medium
15
+ tool_requires_approval false
16
+ tool_schema({
17
+ type: "object",
18
+ properties: {
19
+ url: {
20
+ type: "string",
21
+ description: "Full URL to fetch (must be https:// or http://)",
22
+ minLength: 10
23
+ },
24
+ headers: {
25
+ type: "object",
26
+ description: "Optional HTTP headers as key-value pairs"
27
+ },
28
+ max_bytes: {
29
+ type: "integer",
30
+ description: "Truncate response at this many bytes (default 32768, max 131072)",
31
+ minimum: 1,
32
+ maximum: 131_072
33
+ }
34
+ },
35
+ required: ["url"]
36
+ })
37
+
38
+ DEFAULT_MAX_BYTES = 32_768
39
+ ALLOWED_SCHEMES = %w[http https].freeze
40
+ ALLOWED_CONTENT_TYPES = %w[
41
+ text/plain text/html text/markdown application/json application/xml
42
+ text/xml text/csv application/yaml text/yaml
43
+ ].freeze
44
+
45
+ def initialize(allowed_hosts: nil, denied_hosts: nil, timeout: 15, **opts)
46
+ super()
47
+ @allowed_hosts = allowed_hosts
48
+ @denied_hosts = Array(denied_hosts)
49
+ @timeout = timeout
50
+ end
51
+
52
+ def call(args, context: {})
53
+ url = args["url"].to_s.strip
54
+ max_bytes = [args["max_bytes"]&.to_i || DEFAULT_MAX_BYTES, 131_072].min
55
+
56
+ uri = parse_and_validate_url!(url)
57
+ check_host!(uri.host)
58
+
59
+ headers = build_headers(args["headers"])
60
+ fetch(uri, headers: headers, max_bytes: max_bytes)
61
+ end
62
+
63
+ private
64
+
65
+ def parse_and_validate_url!(url)
66
+ uri = URI.parse(url)
67
+ unless ALLOWED_SCHEMES.include?(uri.scheme)
68
+ raise OllamaAgent::Error, "http_get: only #{ALLOWED_SCHEMES.join("/")} URLs are allowed"
69
+ end
70
+
71
+ uri
72
+ rescue URI::InvalidURIError => e
73
+ raise OllamaAgent::Error, "http_get: invalid URL — #{e.message}"
74
+ end
75
+
76
+ def check_host!(host)
77
+ if @allowed_hosts && !@allowed_hosts.any? { |pat| pat === host }
78
+ raise OllamaAgent::Error, "http_get: host #{host} is not on the allowlist"
79
+ end
80
+
81
+ if @denied_hosts.any? { |pat| pat === host }
82
+ raise OllamaAgent::Error, "http_get: host #{host} is blocked"
83
+ end
84
+
85
+ # Block private/internal addresses
86
+ if private_address?(host)
87
+ raise OllamaAgent::Error, "http_get: requests to internal/private addresses are blocked"
88
+ end
89
+ end
90
+
91
+ def private_address?(host)
92
+ return false if host.nil?
93
+
94
+ private_patterns = [
95
+ /\A127\./,
96
+ /\A10\./,
97
+ /\A172\.(1[6-9]|2\d|3[01])\./,
98
+ /\A192\.168\./,
99
+ /\Alocalhost\z/i,
100
+ /\A::1\z/,
101
+ /\A0\.0\.0\.0\z/,
102
+ /\.local\z/i
103
+ ]
104
+ private_patterns.any? { |pat| pat === host }
105
+ end
106
+
107
+ def build_headers(custom)
108
+ headers = {
109
+ "User-Agent" => "OllamaAgent/#{OllamaAgent::VERSION rescue "0"} (+https://github.com/shubhamtaywade82/ollama_agent)"
110
+ }
111
+ return headers unless custom.is_a?(Hash)
112
+
113
+ # Strip dangerous headers
114
+ safe_custom = custom.reject { |k, _| k.to_s.downcase.match?(/authorization|cookie|set-cookie/) }
115
+ headers.merge(safe_custom)
116
+ end
117
+
118
+ def fetch(uri, headers:, max_bytes:)
119
+ use_ssl = uri.scheme == "https"
120
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl,
121
+ read_timeout: @timeout, open_timeout: 5) do |http|
122
+ req = Net::HTTP::Get.new(uri)
123
+ headers.each { |k, v| req[k] = v }
124
+ http.request(req)
125
+ end
126
+
127
+ handle_response(response, max_bytes)
128
+ rescue Net::OpenTimeout, Net::ReadTimeout
129
+ "Error: request timed out after #{@timeout}s"
130
+ rescue SocketError => e
131
+ "Error: #{e.message}"
132
+ end
133
+
134
+ def handle_response(resp, max_bytes)
135
+ status = resp.code.to_i
136
+ ct = resp["content-type"].to_s.split(";").first.strip
137
+
138
+ unless (200..299).cover?(status)
139
+ return "HTTP #{status}: #{resp.message}"
140
+ end
141
+
142
+ unless ALLOWED_CONTENT_TYPES.any? { |allowed| ct.start_with?(allowed) }
143
+ return "Blocked: content-type #{ct.inspect} is not a text or JSON type"
144
+ end
145
+
146
+ body = resp.body.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
147
+ body = body.byteslice(0, max_bytes) + "\n...[truncated]" if body.bytesize > max_bytes
148
+ body
149
+ end
150
+ end
151
+
152
+ # HTTP POST tool — for sending data to APIs
153
+ class HttpPost < Base
154
+ tool_name "http_post"
155
+ tool_description "Send a JSON POST request to an API endpoint"
156
+ tool_risk :high
157
+ tool_requires_approval true
158
+ tool_schema({
159
+ type: "object",
160
+ properties: {
161
+ url: { type: "string", description: "Full URL", minLength: 10 },
162
+ body: { type: "object", description: "JSON body to send" },
163
+ headers: { type: "object", description: "Optional HTTP headers" }
164
+ },
165
+ required: ["url", "body"]
166
+ })
167
+
168
+ def initialize(allowed_hosts: nil, timeout: 30, **opts)
169
+ super()
170
+ @allowed_hosts = allowed_hosts
171
+ @timeout = timeout
172
+ end
173
+
174
+ def call(args, context: {})
175
+ return "http_post is disabled in read-only mode" if context[:read_only]
176
+
177
+ url = args["url"].to_s.strip
178
+ body = args["body"]
179
+
180
+ uri = URI.parse(url)
181
+ raise OllamaAgent::Error, "http_post: only https/http URLs" unless %w[http https].include?(uri.scheme)
182
+
183
+ if @allowed_hosts && !@allowed_hosts.any? { |pat| pat === uri.host }
184
+ raise OllamaAgent::Error, "http_post: host #{uri.host} not on allowlist"
185
+ end
186
+
187
+ headers = (args["headers"] || {}).merge("Content-Type" => "application/json")
188
+
189
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
190
+ read_timeout: @timeout, open_timeout: 5) do |http|
191
+ req = Net::HTTP::Post.new(uri)
192
+ headers.each { |k, v| req[k] = v }
193
+ req.body = JSON.generate(body)
194
+ resp = http.request(req)
195
+ "HTTP #{resp.code}: #{resp.body.to_s.byteslice(0, 8192)}"
196
+ end
197
+ rescue StandardError => e
198
+ "Error: #{e.message}"
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module OllamaAgent
6
+ module Tools
7
+ # Store a fact in long-term memory for future sessions.
8
+ class MemoryStore < Base
9
+ tool_name "memory_store"
10
+ tool_description "Store a key-value fact in persistent long-term memory for use in future sessions"
11
+ tool_risk :low
12
+ tool_requires_approval false
13
+ tool_schema({
14
+ type: "object",
15
+ properties: {
16
+ key: { type: "string", description: "Unique key for this fact", minLength: 1 },
17
+ value: { type: "string", description: "Value to store" },
18
+ namespace: { type: "string", description: "Namespace (default: 'default')" }
19
+ },
20
+ required: ["key", "value"]
21
+ })
22
+
23
+ def call(args, context: {})
24
+ memory = context[:memory_manager]
25
+ return "memory_store: no memory manager in context" unless memory
26
+
27
+ key = args["key"].to_s.strip
28
+ value = args["value"].to_s
29
+ namespace = args["namespace"] || "default"
30
+
31
+ memory.remember(key, value, tier: :long_term, namespace: namespace)
32
+ "Stored: #{key} = #{value.inspect[0, 80]}"
33
+ end
34
+ end
35
+
36
+ # Recall a fact from long-term memory.
37
+ class MemoryRecall < Base
38
+ tool_name "memory_recall"
39
+ tool_description "Recall a stored fact from long-term memory by key"
40
+ tool_risk :low
41
+ tool_requires_approval false
42
+ tool_schema({
43
+ type: "object",
44
+ properties: {
45
+ key: { type: "string", description: "Key to look up", minLength: 1 },
46
+ namespace: { type: "string", description: "Namespace (default: 'default')" }
47
+ },
48
+ required: ["key"]
49
+ })
50
+
51
+ def call(args, context: {})
52
+ memory = context[:memory_manager]
53
+ return "memory_recall: no memory manager in context" unless memory
54
+
55
+ key = args["key"].to_s.strip
56
+ namespace = args["namespace"] || "default"
57
+ value = memory.recall(key, namespace: namespace)
58
+
59
+ value.nil? ? "No memory found for key: #{key}" : value.to_s
60
+ end
61
+ end
62
+
63
+ # List stored memory keys
64
+ class MemoryList < Base
65
+ tool_name "memory_list"
66
+ tool_description "List all keys stored in long-term memory"
67
+ tool_risk :low
68
+ tool_requires_approval false
69
+ tool_schema({
70
+ type: "object",
71
+ properties: {
72
+ namespace: { type: "string", description: "Namespace to list (default: 'default')" }
73
+ },
74
+ required: []
75
+ })
76
+
77
+ def call(args, context: {})
78
+ memory = context[:memory_manager]
79
+ return "memory_list: no memory manager in context" unless memory
80
+
81
+ namespace = args["namespace"] || "default"
82
+ entries = memory.list(namespace: namespace)
83
+
84
+ return "No memories stored in namespace: #{namespace}" if entries.empty?
85
+
86
+ entries.map { |k, v| "#{k}: #{v.to_s[0, 60]}" }.join("\n")
87
+ end
88
+ end
89
+
90
+ # Delete a stored memory key
91
+ class MemoryDelete < Base
92
+ tool_name "memory_delete"
93
+ tool_description "Delete a key from long-term memory"
94
+ tool_risk :medium
95
+ tool_requires_approval false
96
+ tool_schema({
97
+ type: "object",
98
+ properties: {
99
+ key: { type: "string", description: "Key to delete", minLength: 1 },
100
+ namespace: { type: "string", description: "Namespace (default: 'default')" }
101
+ },
102
+ required: ["key"]
103
+ })
104
+
105
+ def call(args, context: {})
106
+ memory = context[:memory_manager]
107
+ return "memory_delete: no memory manager in context" unless memory
108
+
109
+ key = args["key"].to_s.strip
110
+ namespace = args["namespace"] || "default"
111
+ memory.forget(key, namespace: namespace)
112
+ "Deleted memory key: #{key}"
113
+ end
114
+ end
115
+ end
116
+ end