rixie 0.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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/bin/rixie +7 -0
- data/lib/rixie/agent/compressor.rb +41 -0
- data/lib/rixie/agent/plan.rb +62 -0
- data/lib/rixie/agent/re_act.rb +53 -0
- data/lib/rixie/agent.rb +122 -0
- data/lib/rixie/cli/commands/base.rb +33 -0
- data/lib/rixie/cli/commands/compress.rb +49 -0
- data/lib/rixie/cli/commands/context.rb +18 -0
- data/lib/rixie/cli/commands/help.rb +21 -0
- data/lib/rixie/cli/commands/model.rb +25 -0
- data/lib/rixie/cli/commands/strategy.rb +50 -0
- data/lib/rixie/cli/commands.rb +8 -0
- data/lib/rixie/cli/markdown.rb +59 -0
- data/lib/rixie/cli/renderer.rb +171 -0
- data/lib/rixie/cli/spinner.rb +47 -0
- data/lib/rixie/cli/terminal.rb +28 -0
- data/lib/rixie/cli.rb +285 -0
- data/lib/rixie/configuration.rb +56 -0
- data/lib/rixie/context/history.rb +62 -0
- data/lib/rixie/context/plan.rb +31 -0
- data/lib/rixie/context/summary.rb +25 -0
- data/lib/rixie/error.rb +34 -0
- data/lib/rixie/event/compression_end.rb +7 -0
- data/lib/rixie/event/compression_start.rb +7 -0
- data/lib/rixie/event/envelope.rb +7 -0
- data/lib/rixie/event/finished.rb +7 -0
- data/lib/rixie/event/llm_call_start.rb +7 -0
- data/lib/rixie/event/run_end.rb +7 -0
- data/lib/rixie/event/run_start.rb +7 -0
- data/lib/rixie/event/task_end.rb +7 -0
- data/lib/rixie/event/task_start.rb +7 -0
- data/lib/rixie/event/thought_completed.rb +7 -0
- data/lib/rixie/event/token.rb +7 -0
- data/lib/rixie/event/tool_call_end.rb +7 -0
- data/lib/rixie/event/tool_call_start.rb +7 -0
- data/lib/rixie/event/tool_calls_completed.rb +7 -0
- data/lib/rixie/event.rb +16 -0
- data/lib/rixie/event_listener.rb +36 -0
- data/lib/rixie/http/client.rb +140 -0
- data/lib/rixie/llm/adapter/dummy.rb +38 -0
- data/lib/rixie/llm/adapter/openai.rb +147 -0
- data/lib/rixie/llm/client/resolver.rb +58 -0
- data/lib/rixie/llm/client.rb +33 -0
- data/lib/rixie/llm/response.rb +19 -0
- data/lib/rixie/llm/tool_call.rb +36 -0
- data/lib/rixie/mcp/http/client.rb +86 -0
- data/lib/rixie/mcp/http.rb +3 -0
- data/lib/rixie/mcp.rb +3 -0
- data/lib/rixie/message.rb +10 -0
- data/lib/rixie/prompt_builder.rb +13 -0
- data/lib/rixie/run.rb +60 -0
- data/lib/rixie/search/base.rb +13 -0
- data/lib/rixie/search/duck_duck_go.rb +66 -0
- data/lib/rixie/search/wikipedia.rb +59 -0
- data/lib/rixie/session.rb +153 -0
- data/lib/rixie/store/base.rb +37 -0
- data/lib/rixie/store/memory.rb +30 -0
- data/lib/rixie/store/null.rb +19 -0
- data/lib/rixie/strategy/plan_execute.rb +65 -0
- data/lib/rixie/strategy/re_act.rb +15 -0
- data/lib/rixie/strategy/simple.rb +14 -0
- data/lib/rixie/subscriber.rb +12 -0
- data/lib/rixie/subscribers/event_severity.rb +23 -0
- data/lib/rixie/subscribers/json_logger.rb +70 -0
- data/lib/rixie/subscribers/logger.rb +65 -0
- data/lib/rixie/task.rb +53 -0
- data/lib/rixie/token_counter.rb +10 -0
- data/lib/rixie/tool/calculator.rb +154 -0
- data/lib/rixie/tool/current_time.rb +30 -0
- data/lib/rixie/tool/fetch.rb +42 -0
- data/lib/rixie/tool/file_list.rb +39 -0
- data/lib/rixie/tool/file_read.rb +53 -0
- data/lib/rixie/tool/file_sandbox.rb +33 -0
- data/lib/rixie/tool/file_search.rb +72 -0
- data/lib/rixie/tool/human_input.rb +24 -0
- data/lib/rixie/tool/web_search.rb +34 -0
- data/lib/rixie/tool/wikipedia_search.rb +38 -0
- data/lib/rixie/tool.rb +23 -0
- data/lib/rixie/tool_executor.rb +34 -0
- data/lib/rixie/version.rb +5 -0
- data/lib/rixie.rb +74 -0
- data/sig/rixie.rbs +4 -0
- metadata +146 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Strategy
|
|
5
|
+
class PlanExecute
|
|
6
|
+
Plan = Data.define(:steps)
|
|
7
|
+
|
|
8
|
+
def run(task:, listener:)
|
|
9
|
+
plan = plan_phase(task:, listener:)
|
|
10
|
+
execute_phase(plan:, task:, listener:)
|
|
11
|
+
task.runs.last.output
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def plan_phase(task:, listener:)
|
|
17
|
+
run = Run.new(
|
|
18
|
+
user_input: task.user_input,
|
|
19
|
+
agent: Agent::Plan.new(base_agent: task.agent),
|
|
20
|
+
context: task.context
|
|
21
|
+
)
|
|
22
|
+
task.runs << run
|
|
23
|
+
run.execute(listener:)
|
|
24
|
+
extract_plan(run)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def execute_phase(plan:, task:, listener:)
|
|
28
|
+
completed_histories = []
|
|
29
|
+
|
|
30
|
+
plan.steps.each do |step|
|
|
31
|
+
run = Run.new(
|
|
32
|
+
user_input: task.user_input,
|
|
33
|
+
agent: task.agent,
|
|
34
|
+
context: task.context + completed_histories + [
|
|
35
|
+
Context::Plan.new(steps: plan.steps, current_step: step)
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
task.runs << run
|
|
39
|
+
run.execute(listener:)
|
|
40
|
+
completed_histories << run.to_history if run.completed?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_plan(run)
|
|
45
|
+
plan_call = run.find_tool_call("plan_done")
|
|
46
|
+
|
|
47
|
+
raise AgentError, "plan_done tool call not found in run steps" if plan_call.nil?
|
|
48
|
+
|
|
49
|
+
raw_steps = plan_call.arguments[:steps] || plan_call.arguments["steps"]
|
|
50
|
+
if raw_steps.is_a?(String)
|
|
51
|
+
begin
|
|
52
|
+
raw_steps = JSON.parse(raw_steps)
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
raise AgentError, "plan_done returned invalid JSON for steps: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
steps = raw_steps.map do |s|
|
|
58
|
+
{title: s[:title] || s["title"], description: s[:description] || s["description"]}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Plan.new(steps: steps)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Strategy
|
|
5
|
+
class ReAct
|
|
6
|
+
def run(task:, listener:)
|
|
7
|
+
agent = Agent::ReAct.new(base_agent: task.agent)
|
|
8
|
+
run = Run.new(user_input: task.user_input, agent: agent, context: task.context)
|
|
9
|
+
task.runs << run
|
|
10
|
+
run.execute(listener:)
|
|
11
|
+
run.output
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Strategy
|
|
5
|
+
class Simple
|
|
6
|
+
def run(task:, listener:)
|
|
7
|
+
run = Run.new(user_input: task.user_input, agent: task.agent, context: task.context)
|
|
8
|
+
task.runs << run
|
|
9
|
+
run.execute(listener:)
|
|
10
|
+
run.output
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Subscriber
|
|
5
|
+
# Registers event handlers on the given listener.
|
|
6
|
+
# Called once per Task execution (new EventListener each time).
|
|
7
|
+
# Implement by calling listener.on(EventClass) { |e| ... } for each event of interest.
|
|
8
|
+
def subscribe(listener)
|
|
9
|
+
raise NotImplementedError, "#{self.class}#subscribe must be implemented"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Maps an Event::* instance to the log severity (:debug, :info, :warn)
|
|
6
|
+
# at which subscribers should emit it. Shared by Subscribers::Logger and
|
|
7
|
+
# Subscribers::JsonLogger so the mapping does not drift between them.
|
|
8
|
+
module EventSeverity
|
|
9
|
+
def self.for(event)
|
|
10
|
+
case event
|
|
11
|
+
when Event::LlmCallStart, Event::ToolCallStart
|
|
12
|
+
:debug
|
|
13
|
+
when Event::ToolCallEnd
|
|
14
|
+
event.result.error? ? :warn : :debug
|
|
15
|
+
when Event::CompressionEnd
|
|
16
|
+
(event.status == "completed") ? :info : :warn
|
|
17
|
+
else
|
|
18
|
+
:info
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
module Subscribers
|
|
7
|
+
class JsonLogger < Rixie::Subscriber
|
|
8
|
+
def initialize(logger:)
|
|
9
|
+
@logger = logger
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def subscribe(listener)
|
|
13
|
+
listener.on(Event::TaskStart) { |envelope|
|
|
14
|
+
e = envelope.event
|
|
15
|
+
emit(envelope, "task_start", user_input: e.user_input, strategy: e.strategy.class.name)
|
|
16
|
+
}
|
|
17
|
+
listener.on(Event::TaskEnd) { |envelope|
|
|
18
|
+
emit(envelope, "task_end", status: envelope.event.status)
|
|
19
|
+
}
|
|
20
|
+
listener.on(Event::RunStart) { |envelope|
|
|
21
|
+
emit(envelope, "run_start", user_input: envelope.event.user_input)
|
|
22
|
+
}
|
|
23
|
+
listener.on(Event::RunEnd) { |envelope|
|
|
24
|
+
emit(envelope, "run_end", status: envelope.event.status)
|
|
25
|
+
}
|
|
26
|
+
listener.on(Event::CompressionStart) { |envelope|
|
|
27
|
+
e = envelope.event
|
|
28
|
+
emit(envelope, "compression_start", entry_count: e.entry_count, keep_recent: e.keep_recent)
|
|
29
|
+
}
|
|
30
|
+
listener.on(Event::CompressionEnd) { |envelope|
|
|
31
|
+
e = envelope.event
|
|
32
|
+
emit(envelope, "compression_end", status: e.status, entry_count: e.entry_count)
|
|
33
|
+
}
|
|
34
|
+
listener.on(Event::LlmCallStart) { |envelope|
|
|
35
|
+
emit(envelope, "llm_call_start", step_count: envelope.event.step_count)
|
|
36
|
+
}
|
|
37
|
+
listener.on(Event::ToolCallStart) { |envelope|
|
|
38
|
+
tc = envelope.event.tool_call
|
|
39
|
+
emit(envelope, "tool_call_start", tool_call: {id: tc.id, name: tc.name, arguments: tc.arguments})
|
|
40
|
+
}
|
|
41
|
+
listener.on(Event::ToolCallEnd) { |envelope|
|
|
42
|
+
tc = envelope.event.tool_call
|
|
43
|
+
r = envelope.event.result
|
|
44
|
+
emit(envelope, "tool_call_end",
|
|
45
|
+
tool_call: {id: tc.id, name: tc.name},
|
|
46
|
+
result: {content: r.content, error: r.error&.message})
|
|
47
|
+
}
|
|
48
|
+
listener.on(Event::Finished) { |envelope|
|
|
49
|
+
emit(envelope, "finished", content: envelope.event.content)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def emit(envelope, type, **payload)
|
|
56
|
+
record = {
|
|
57
|
+
type: type,
|
|
58
|
+
occurred_at: envelope.occurred_at.iso8601,
|
|
59
|
+
session_id: envelope.session_id,
|
|
60
|
+
task_id: envelope.task_id,
|
|
61
|
+
run_id: envelope.run_id,
|
|
62
|
+
seq: envelope.sequence_number,
|
|
63
|
+
event_id: envelope.event_id,
|
|
64
|
+
payload: payload
|
|
65
|
+
}
|
|
66
|
+
@logger.public_send(EventSeverity.for(envelope.event)) { JSON.generate(record) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Subscribers
|
|
5
|
+
class Logger < Rixie::Subscriber
|
|
6
|
+
def initialize(logger:)
|
|
7
|
+
@logger = logger
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def subscribe(listener)
|
|
11
|
+
listener.on(Event::TaskStart) { |envelope|
|
|
12
|
+
e = envelope.event
|
|
13
|
+
log(envelope) { "[Task] started: #{e.user_input.inspect} strategy=#{e.strategy.class.name} #{meta(envelope)}" }
|
|
14
|
+
}
|
|
15
|
+
listener.on(Event::TaskEnd) { |envelope|
|
|
16
|
+
log(envelope) { "[Task] #{envelope.event.status} #{meta(envelope)}" }
|
|
17
|
+
}
|
|
18
|
+
listener.on(Event::RunStart) { |envelope|
|
|
19
|
+
log(envelope) { "[Run] started: #{envelope.event.user_input.inspect} #{meta(envelope)}" }
|
|
20
|
+
}
|
|
21
|
+
listener.on(Event::RunEnd) { |envelope|
|
|
22
|
+
log(envelope) { "[Run] #{envelope.event.status} #{meta(envelope)}" }
|
|
23
|
+
}
|
|
24
|
+
listener.on(Event::CompressionStart) { |envelope|
|
|
25
|
+
e = envelope.event
|
|
26
|
+
log(envelope) { "[Session] compression started: #{e.entry_count} entries (keep_recent: #{e.keep_recent}) #{meta(envelope)}" }
|
|
27
|
+
}
|
|
28
|
+
listener.on(Event::CompressionEnd) { |envelope|
|
|
29
|
+
e = envelope.event
|
|
30
|
+
msg = (e.status == "completed") ? "compression completed: #{e.entry_count} entries after" : "compression failed"
|
|
31
|
+
log(envelope) { "[Session] #{msg} #{meta(envelope)}" }
|
|
32
|
+
}
|
|
33
|
+
listener.on(Event::LlmCallStart) { |envelope|
|
|
34
|
+
log(envelope) { "[Agent] llm_call ##{envelope.event.step_count} #{meta(envelope)}" }
|
|
35
|
+
}
|
|
36
|
+
listener.on(Event::ToolCallStart) { |envelope|
|
|
37
|
+
e = envelope.event
|
|
38
|
+
log(envelope) { "[Agent] tool_call: #{e.tool_call.name}(#{e.tool_call.arguments}) #{meta(envelope)}" }
|
|
39
|
+
}
|
|
40
|
+
listener.on(Event::ToolCallEnd) { |envelope|
|
|
41
|
+
log(envelope) { "[Agent] tool_result: #{envelope.event.result.content.inspect} #{meta(envelope)}" }
|
|
42
|
+
}
|
|
43
|
+
listener.on(Event::Finished) { |envelope|
|
|
44
|
+
log(envelope) { "[Agent] finish: #{envelope.event.content.inspect} #{meta(envelope)}" }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def log(envelope, &block)
|
|
51
|
+
@logger.public_send(EventSeverity.for(envelope.event), &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def meta(envelope)
|
|
55
|
+
parts = []
|
|
56
|
+
parts << "session_id=#{envelope.session_id}" if envelope.session_id
|
|
57
|
+
parts << "task_id=#{envelope.task_id}" if envelope.task_id
|
|
58
|
+
parts << "run_id=#{envelope.run_id}" if envelope.run_id
|
|
59
|
+
parts << "seq=#{envelope.sequence_number}"
|
|
60
|
+
parts << "event_id=#{envelope.event_id}"
|
|
61
|
+
"[#{parts.join(" ")}]"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/rixie/task.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class Task
|
|
7
|
+
attr_reader :id, :user_input, :agent, :context, :strategy, :runs, :status, :output
|
|
8
|
+
|
|
9
|
+
def initialize(user_input:, agent:, context:, strategy:, subscribers: [], session_id: nil)
|
|
10
|
+
@id = SecureRandom.uuid
|
|
11
|
+
@session_id = session_id
|
|
12
|
+
@user_input = user_input
|
|
13
|
+
@agent = agent
|
|
14
|
+
@context = context
|
|
15
|
+
@strategy = strategy
|
|
16
|
+
@subscribers = subscribers
|
|
17
|
+
@runs = []
|
|
18
|
+
@status = "running"
|
|
19
|
+
@output = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute
|
|
23
|
+
listener = EventListener.new(session_id: @session_id, task_id: @id)
|
|
24
|
+
listener.on(Event::ToolCallsCompleted) { |envelope|
|
|
25
|
+
e = envelope.event
|
|
26
|
+
runs.last.add_step(tool_calls: e.tool_calls, tool_results: e.tool_results)
|
|
27
|
+
}
|
|
28
|
+
@subscribers.each { |s| s.subscribe(listener) }
|
|
29
|
+
listener.emit(Event::TaskStart.new(user_input: @user_input, strategy: @strategy))
|
|
30
|
+
|
|
31
|
+
result = @strategy.run(task: self, listener:)
|
|
32
|
+
@output = result
|
|
33
|
+
@status = "completed"
|
|
34
|
+
listener.emit(Event::TaskEnd.new(output: @output, status: @status))
|
|
35
|
+
rescue
|
|
36
|
+
@status = "failed"
|
|
37
|
+
listener&.emit(Event::TaskEnd.new(output: nil, status: @status))
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def completed?
|
|
42
|
+
@status == "completed"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failed?
|
|
46
|
+
@status == "failed"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_history
|
|
50
|
+
@runs.select(&:completed?).map(&:to_history)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module TokenCounter
|
|
5
|
+
# Approximate token count using character length (1 token ≈ 4 characters).
|
|
6
|
+
# Replace via Rixie.configure { |c| c.token_counter = your_callable }
|
|
7
|
+
# to use an exact counter such as tiktoken.
|
|
8
|
+
DEFAULT = ->(messages) { messages.sum { |m| m.content.to_s.length } / 4 }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class Tool
|
|
7
|
+
# Recursive-descent parser for arithmetic expressions.
|
|
8
|
+
# Supports: + - * / % ^ ** (), unary minus/plus, ints / floats / scientific notation.
|
|
9
|
+
class CalculatorParser
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
ADDITIVE = ["+", "-"].freeze
|
|
13
|
+
MULTIPLICATIVE = ["*", "/", "%"].freeze
|
|
14
|
+
POWER = ["**", "^"].freeze
|
|
15
|
+
|
|
16
|
+
def self.evaluate(expression)
|
|
17
|
+
new(expression).parse
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(expression)
|
|
21
|
+
@tokens = tokenize(expression)
|
|
22
|
+
@pos = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse
|
|
26
|
+
result = expression
|
|
27
|
+
raise Error, "Unexpected token: #{peek.inspect}" if peek
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def tokenize(input)
|
|
34
|
+
scanner = StringScanner.new(input.to_s)
|
|
35
|
+
tokens = []
|
|
36
|
+
until scanner.eos?
|
|
37
|
+
scanner.skip(/\s+/)
|
|
38
|
+
break if scanner.eos?
|
|
39
|
+
|
|
40
|
+
if (num = scanner.scan(/\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/))
|
|
41
|
+
tokens << (num.match?(/[.eE]/) ? num.to_f : num.to_i)
|
|
42
|
+
elsif (op = scanner.scan(/\*\*|[+\-*\/%^()]/))
|
|
43
|
+
tokens << op
|
|
44
|
+
else
|
|
45
|
+
raise Error, "Invalid character at position #{scanner.pos}: #{scanner.peek(1).inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
tokens
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def peek = @tokens[@pos]
|
|
52
|
+
|
|
53
|
+
def consume
|
|
54
|
+
token = @tokens[@pos]
|
|
55
|
+
@pos += 1
|
|
56
|
+
token
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def expression
|
|
60
|
+
left = term
|
|
61
|
+
while ADDITIVE.include?(peek)
|
|
62
|
+
op = consume
|
|
63
|
+
right = term
|
|
64
|
+
left = (op == "+") ? left + right : left - right
|
|
65
|
+
end
|
|
66
|
+
left
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def term
|
|
70
|
+
left = power
|
|
71
|
+
while MULTIPLICATIVE.include?(peek)
|
|
72
|
+
op = consume
|
|
73
|
+
right = power
|
|
74
|
+
left = apply_multiplicative(op, left, right)
|
|
75
|
+
end
|
|
76
|
+
left
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_multiplicative(op, left, right)
|
|
80
|
+
case op
|
|
81
|
+
when "*" then left * right
|
|
82
|
+
when "/"
|
|
83
|
+
raise Error, "Division by zero" if right.zero?
|
|
84
|
+
# Promote integer division to float to avoid surprising truncation.
|
|
85
|
+
(left.is_a?(Integer) && right.is_a?(Integer)) ? left.fdiv(right) : left / right
|
|
86
|
+
when "%"
|
|
87
|
+
raise Error, "Modulo by zero" if right.zero?
|
|
88
|
+
left % right
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def power
|
|
93
|
+
left = unary
|
|
94
|
+
if POWER.include?(peek)
|
|
95
|
+
consume
|
|
96
|
+
right = power # right-associative
|
|
97
|
+
left **= right
|
|
98
|
+
end
|
|
99
|
+
left
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unary
|
|
103
|
+
case peek
|
|
104
|
+
when "-" then consume
|
|
105
|
+
-unary
|
|
106
|
+
when "+" then consume
|
|
107
|
+
unary
|
|
108
|
+
else primary
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def primary
|
|
113
|
+
token = consume
|
|
114
|
+
return parse_parenthesized if token == "("
|
|
115
|
+
return token if token.is_a?(Numeric)
|
|
116
|
+
|
|
117
|
+
raise Error, "Unexpected token: #{token.inspect}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_parenthesized
|
|
121
|
+
value = expression
|
|
122
|
+
raise Error, "Expected ')'" unless consume == ")"
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
private_constant :CalculatorParser
|
|
127
|
+
|
|
128
|
+
Calculator = Tool.new(
|
|
129
|
+
name: "calculator",
|
|
130
|
+
description: "Evaluate an arithmetic expression and return the result. " \
|
|
131
|
+
"Supports + - * / % and ^ (or **) for exponentiation, plus parentheses. " \
|
|
132
|
+
"Use this for any non-trivial arithmetic — LLMs are unreliable at calculation.",
|
|
133
|
+
input_schema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
expression: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "An arithmetic expression, e.g. '(2 + 3) * 4 ^ 2'"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
required: ["expression"]
|
|
142
|
+
},
|
|
143
|
+
call: ->(args) {
|
|
144
|
+
begin
|
|
145
|
+
expr = args["expression"] || args[:expression]
|
|
146
|
+
result = CalculatorParser.evaluate(expr)
|
|
147
|
+
result.to_s
|
|
148
|
+
rescue CalculatorParser::Error => e
|
|
149
|
+
"Error: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class Tool
|
|
7
|
+
CurrentTime = Tool.new(
|
|
8
|
+
name: "current_time",
|
|
9
|
+
description: "Get the current date and time as an ISO 8601 string. " \
|
|
10
|
+
"LLMs do not know the current time on their own — call this " \
|
|
11
|
+
"when the user asks about \"now\", \"today\", relative dates, " \
|
|
12
|
+
"or anything time-sensitive.",
|
|
13
|
+
input_schema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
timezone: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Either 'local' (system local time) or 'utc'. Defaults to 'local'.",
|
|
19
|
+
enum: ["local", "utc"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
call: ->(args) {
|
|
24
|
+
tz = (args["timezone"] || args[:timezone] || "local").to_s.downcase
|
|
25
|
+
time = (tz == "utc") ? Time.now.utc : Time.now
|
|
26
|
+
time.iso8601
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Tool
|
|
5
|
+
Fetch = Tool.new(
|
|
6
|
+
name: "fetch",
|
|
7
|
+
description: "Fetch the content of a URL and return the readable text. Useful for reading web pages found via web_search.",
|
|
8
|
+
input_schema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
url: {
|
|
12
|
+
type: "string",
|
|
13
|
+
description: "The URL to fetch"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
required: ["url"]
|
|
17
|
+
},
|
|
18
|
+
call: ->(args) {
|
|
19
|
+
begin
|
|
20
|
+
require "nokogiri"
|
|
21
|
+
rescue LoadError
|
|
22
|
+
raise Rixie::ConfigurationError, "nokogiri gem is required for Tool::Fetch. Add `gem 'nokogiri'` to your Gemfile."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
url = args["url"] || args[:url]
|
|
26
|
+
response = Rixie::Http::Client.new.get(url)
|
|
27
|
+
content_type = response[:headers]["content-type"]&.first.to_s
|
|
28
|
+
|
|
29
|
+
next response[:body] unless content_type.include?("text/html")
|
|
30
|
+
|
|
31
|
+
doc = Nokogiri::HTML(response[:body].to_s)
|
|
32
|
+
doc.css("nav, script, style, footer, header, aside, img, link, figure, blockquote, button, noscript, iframe").remove
|
|
33
|
+
doc.css("pre").each { |pre| pre.replace("[code block omitted]") }
|
|
34
|
+
doc.css("body").text
|
|
35
|
+
.gsub(/[^\S\n]+/, " ")
|
|
36
|
+
.gsub(/^ +| +$/, "")
|
|
37
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
38
|
+
.strip
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_sandbox"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class Tool
|
|
7
|
+
build = ->(root_dir: nil) {
|
|
8
|
+
Tool.new(
|
|
9
|
+
name: "file_list",
|
|
10
|
+
description: "List files matching a glob pattern within the configured root directory. " \
|
|
11
|
+
"Patterns are relative to root_dir; matches that escape root_dir are filtered out.",
|
|
12
|
+
input_schema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
pattern: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Glob pattern, e.g. '**/*.rb', 'src/*.js', 'lib/**/*'"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
required: ["pattern"]
|
|
21
|
+
},
|
|
22
|
+
call: ->(args) {
|
|
23
|
+
base = FileSandbox.root(root_dir)
|
|
24
|
+
pattern = (args["pattern"] || args[:pattern]).to_s
|
|
25
|
+
matches = Dir.glob(pattern, base: base).select do |rel|
|
|
26
|
+
FileSandbox.resolve(base, rel)
|
|
27
|
+
true
|
|
28
|
+
rescue FileSandbox::PathError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
matches.sort!
|
|
32
|
+
matches.empty? ? "No files matched." : matches.join("\n")
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
FileList = build.call
|
|
37
|
+
FileList.define_singleton_method(:with, &build)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_sandbox"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class Tool
|
|
7
|
+
DEFAULT_FILE_READ_LIMIT = 2000
|
|
8
|
+
private_constant :DEFAULT_FILE_READ_LIMIT
|
|
9
|
+
|
|
10
|
+
build = ->(root_dir: nil) {
|
|
11
|
+
Tool.new(
|
|
12
|
+
name: "file_read",
|
|
13
|
+
description: "Read a text file from the configured root directory. " \
|
|
14
|
+
"Paths are relative to root_dir; absolute or '..' paths that " \
|
|
15
|
+
"escape root_dir are rejected. Binary files are not returned.",
|
|
16
|
+
input_schema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Path to the file, relative to root_dir"
|
|
22
|
+
},
|
|
23
|
+
offset: {
|
|
24
|
+
type: "integer",
|
|
25
|
+
description: "Line number to start reading from (1-indexed). Defaults to 1."
|
|
26
|
+
},
|
|
27
|
+
limit: {
|
|
28
|
+
type: "integer",
|
|
29
|
+
description: "Maximum number of lines to read. Defaults to #{DEFAULT_FILE_READ_LIMIT}."
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
required: ["path"]
|
|
33
|
+
},
|
|
34
|
+
call: ->(args) {
|
|
35
|
+
begin
|
|
36
|
+
rel_path = args["path"] || args[:path]
|
|
37
|
+
target = FileSandbox.resolve(root_dir, rel_path)
|
|
38
|
+
next "Error: File not found: #{rel_path}" unless File.file?(target)
|
|
39
|
+
next "Error: Binary file not supported: #{rel_path}" if FileSandbox.binary?(target)
|
|
40
|
+
|
|
41
|
+
offset = (args["offset"] || args[:offset] || 1).to_i
|
|
42
|
+
limit = (args["limit"] || args[:limit] || DEFAULT_FILE_READ_LIMIT).to_i
|
|
43
|
+
File.foreach(target).drop(offset - 1).take(limit).join
|
|
44
|
+
rescue FileSandbox::PathError => e
|
|
45
|
+
"Error: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
FileRead = build.call
|
|
51
|
+
FileRead.define_singleton_method(:with, &build)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Tool
|
|
5
|
+
# Shared path resolution + safety check for file_read / file_list / file_search.
|
|
6
|
+
# Rejects paths that escape the configured root directory after expansion.
|
|
7
|
+
module FileSandbox
|
|
8
|
+
class PathError < StandardError; end
|
|
9
|
+
|
|
10
|
+
def self.root(root_dir)
|
|
11
|
+
File.expand_path(root_dir || Dir.pwd)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.resolve(root_dir, relative_path)
|
|
15
|
+
segments = relative_path.to_s.split(%r{[/\\]})
|
|
16
|
+
raise PathError, "Path '#{relative_path}' contains '..' segment" if segments.include?("..")
|
|
17
|
+
|
|
18
|
+
base = root(root_dir)
|
|
19
|
+
target = File.expand_path(relative_path.to_s, base)
|
|
20
|
+
return target if target == base || target.start_with?(base + File::SEPARATOR)
|
|
21
|
+
|
|
22
|
+
raise PathError, "Path '#{relative_path}' is outside root_dir"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
BINARY_PROBE_BYTES = 8192
|
|
26
|
+
private_constant :BINARY_PROBE_BYTES
|
|
27
|
+
|
|
28
|
+
def self.binary?(path)
|
|
29
|
+
File.open(path, "rb") { |f| f.read(BINARY_PROBE_BYTES).to_s.include?("\0") }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|