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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +14 -3
- data/lib/ollama_agent/agent/agent_config.rb +19 -2
- data/lib/ollama_agent/agent/client_wiring.rb +3 -8
- data/lib/ollama_agent/agent/session_wiring.rb +37 -3
- data/lib/ollama_agent/agent.rb +82 -6
- data/lib/ollama_agent/cli/repl.rb +159 -0
- data/lib/ollama_agent/cli/repl_shared.rb +229 -0
- data/lib/ollama_agent/cli/tui_repl.rb +149 -0
- data/lib/ollama_agent/cli.rb +129 -49
- data/lib/ollama_agent/core/action_envelope.rb +82 -0
- data/lib/ollama_agent/core/budget.rb +90 -0
- data/lib/ollama_agent/core/loop_detector.rb +67 -0
- data/lib/ollama_agent/core/schema_validator.rb +136 -0
- data/lib/ollama_agent/core/trace_logger.rb +138 -0
- data/lib/ollama_agent/external_agents/probe.rb +23 -3
- data/lib/ollama_agent/indexing/context_packer.rb +140 -0
- data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
- data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
- data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
- data/lib/ollama_agent/memory/long_term.rb +109 -0
- data/lib/ollama_agent/memory/manager.rb +121 -0
- data/lib/ollama_agent/memory/session_memory.rb +93 -0
- data/lib/ollama_agent/memory/short_term.rb +66 -0
- data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
- data/lib/ollama_agent/ollama_connection.rb +30 -0
- data/lib/ollama_agent/plugins/loader.rb +95 -0
- data/lib/ollama_agent/plugins/registry.rb +103 -0
- data/lib/ollama_agent/providers/anthropic.rb +245 -0
- data/lib/ollama_agent/providers/base.rb +79 -0
- data/lib/ollama_agent/providers/ollama.rb +118 -0
- data/lib/ollama_agent/providers/openai.rb +215 -0
- data/lib/ollama_agent/providers/registry.rb +76 -0
- data/lib/ollama_agent/providers/router.rb +93 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
- data/lib/ollama_agent/runner.rb +25 -4
- data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
- data/lib/ollama_agent/runtime/permissions.rb +103 -0
- data/lib/ollama_agent/runtime/policies.rb +100 -0
- data/lib/ollama_agent/runtime/sandbox.rb +130 -0
- data/lib/ollama_agent/streaming/hooks.rb +3 -1
- data/lib/ollama_agent/tools/base.rb +108 -0
- data/lib/ollama_agent/tools/git_tools.rb +176 -0
- data/lib/ollama_agent/tools/http_tools.rb +202 -0
- data/lib/ollama_agent/tools/memory_tools.rb +116 -0
- data/lib/ollama_agent/tools/shell_tools.rb +208 -0
- data/lib/ollama_agent/tui.rb +183 -0
- data/lib/ollama_agent/tui_slash_reader.rb +147 -0
- data/lib/ollama_agent/tui_user_prompt.rb +45 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +46 -1
- 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
|