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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8323f9bcb6b861edc4ef4db226ea11787008d527b68002827308795e4931bedd
|
|
4
|
+
data.tar.gz: cb598addd4243e6dc89140183b144017ace53b5e2774c861c8f37198583be079
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: eb038298807744075f868d8fd45053b32f6512939a5c51b5d798ffbdf4d59bdb11eb88477f836545bd179970839be4ce1c4f6c93b77a3a98e25a693cdcc0f766
|
|
7
|
+
data.tar.gz: de459ad3a0dcab97f37b0972f42bd642207bb6193d137bcd232a3ff7afb547e25121f1205f2ad0cef3a1203a892dd76f87c24d030c916869cae513fc6a056926
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-05-29
|
|
11
|
+
|
|
12
|
+
Initial release. Requires Ruby 3.4 or newer.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Conceptual hierarchy: `Session → Task → Run → Agent` with the think+act loop.
|
|
17
|
+
- Strategies: `Simple`, `PlanExecute`, `ReAct`.
|
|
18
|
+
- Built-in tools: `HumanInput`, `Fetch`, `WebSearch`, `WikipediaSearch`,
|
|
19
|
+
`CurrentTime`, `Calculator`, `FileRead`, `FileList`, `FileSearch`.
|
|
20
|
+
- LLM provider support: `openai` and any OpenAI-compatible endpoint
|
|
21
|
+
(GitHub Models, Ollama).
|
|
22
|
+
- MCP (Model Context Protocol) HTTP client for importing remote tools.
|
|
23
|
+
- Interactive CLI (`bin/rixie`) with slash commands, tab completion,
|
|
24
|
+
and markdown rendering.
|
|
25
|
+
- Streaming via `Session#live`.
|
|
26
|
+
- Context compression via `Session#compress!`.
|
|
27
|
+
- Multi-agent orchestration: wrap a `Session` as a tool.
|
|
28
|
+
- Event bus with pluggable subscribers. Built-in:
|
|
29
|
+
`Subscribers::Logger` (text) and `Subscribers::JsonLogger` (one JSON
|
|
30
|
+
object per event). Both dispatch through `Subscribers::EventSeverity`,
|
|
31
|
+
which maps each event to a `::Logger` severity (`:debug` for
|
|
32
|
+
per-iteration events, `:warn` for failures, `:info` otherwise).
|
|
33
|
+
- Shared `Rixie::Http::Client` with SSRF protection, gzip/deflate
|
|
34
|
+
decoding, and `allow_private:` opt-out for trusted endpoints.
|
|
35
|
+
- Optional runtime dependencies: `openai`, `nokogiri`, `cli-ui`.
|
|
36
|
+
Each raises `Rixie::ConfigurationError` with an actionable message
|
|
37
|
+
when used without the gem installed.
|
|
38
|
+
|
|
39
|
+
[Unreleased]: https://github.com/madogiwa0124/rixie/compare/v0.1.0...HEAD
|
|
40
|
+
[0.1.0]: https://github.com/madogiwa0124/rixie/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 madogiwa0124
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Rixie
|
|
2
|
+
|
|
3
|
+
AI agent orchestration for Ruby.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Rixie is a standalone Ruby gem for orchestrating AI agents — no Rails required.
|
|
8
|
+
|
|
9
|
+
An **Agent** thinks and acts via an LLM and a set of tools, looping until it reaches a final answer. A **Session** manages the full conversation, accumulating history across multiple chats. A **Strategy** controls how a goal is accomplished: the default `Simple` strategy runs a single agent loop, while `PlanExecute` first builds a step-by-step plan and then executes each step in sequence.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "rixie"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Rixie keeps its runtime footprint small. The following gems are **optional** — add them only for the features you actually use. Each is loaded lazily and raises `Rixie::ConfigurationError` with an actionable message if missing.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "openai" # required for the openai / ollama provider adapter (and any OpenAI-compatible endpoint)
|
|
23
|
+
gem "nokogiri" # required for Rixie::Tool::Fetch and Rixie::Tool::WebSearch (DuckDuckGo HTML parsing)
|
|
24
|
+
gem "cli-ui" # required to run the `rixie` CLI (bin/rixie)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "rixie"
|
|
31
|
+
|
|
32
|
+
Rixie.configure do |config|
|
|
33
|
+
config.default_provider = "openai"
|
|
34
|
+
config.default_model = "gpt-4.1-mini"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
session = Rixie::Session.new(instructions: "You are a helpful assistant.")
|
|
38
|
+
puts session.chat("What is the capital of France?")
|
|
39
|
+
# => "The capital of France is Paris."
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Architecture
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Session # manages the full conversation; accumulates history across chats
|
|
46
|
+
└── Task # accomplishes a single goal; owns a Strategy
|
|
47
|
+
└── Run × N # one LLM loop per step; calls Agent#think
|
|
48
|
+
└── Agent # thinks and acts: calls the LLM, executes tools, loops until done
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Class | Responsibility |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `Session` | Entry point. Resolves config, creates `Agent` and `LLM::Client`, exposes `chat` and `live`. |
|
|
54
|
+
| `Task` | Runs a `Strategy` and accumulates `Run` results. Manages an `EventListener`. |
|
|
55
|
+
| `Run` | Calls `agent.think` once. Accumulates tool-call steps. |
|
|
56
|
+
| `Agent` | The think-act loop: calls the LLM, executes tools, emits events. |
|
|
57
|
+
| `Strategy` | Controls how many Runs a Task executes. `Simple` = 1 Run; `PlanExecute` = plan + N Runs. |
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- **[Configuration](docs/configuration.md)** — Configure providers, models, and persistence. Use OpenAI-compatible endpoints (GitHub Models, Ollama) or plug in a custom LLM adapter.
|
|
62
|
+
- **[CLI](docs/cli.md)** — Interactive REPL with slash commands, tab completion, and extensibility via custom commands and tools.
|
|
63
|
+
- **[Tools](docs/tools.md)** — Define your own tools and use the built-ins: web (Fetch, WebSearch, WikipediaSearch), filesystem (FileRead, FileList, FileSearch), utilities (CurrentTime, Calculator), and HumanInput for human-in-the-loop flows.
|
|
64
|
+
- **[Strategies](docs/strategies.md)** — `Simple` for single-loop tasks, `PlanExecute` for plan-then-execute multi-step tasks, `ReAct` for explicit Thought → Action → Observation reasoning traces.
|
|
65
|
+
- **[MCP](docs/mcp.md)** — Connect to any MCP (Model Context Protocol) server over HTTP and import its tools automatically.
|
|
66
|
+
- **[Multi-Agent Orchestration](docs/multi-agent.md)** — Compose agents by wrapping a `Session` as a tool, with isolated context per sub-agent.
|
|
67
|
+
- **[Subscribers](docs/subscribers.md)** — Observe agent behavior via the event bus — built-in logging plus pluggable subscribers (e.g. OpenTelemetry).
|
|
68
|
+
- **[Streaming](docs/streaming.md)** — Stream tokens, tool calls, and lifecycle events via `Session#live`.
|
|
69
|
+
- **[Context Compression](docs/context-compression.md)** — Summarize accumulated history to control token usage in long sessions.
|
data/bin/rixie
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Agent
|
|
5
|
+
class Compressor
|
|
6
|
+
DEFAULT_SUMMARIZATION_INSTRUCTIONS = <<~INSTRUCTIONS
|
|
7
|
+
You are a conversation summarizer.
|
|
8
|
+
Summarize the following conversation history concisely,
|
|
9
|
+
preserving key facts, decisions, and context needed
|
|
10
|
+
for future interactions. Do not add commentary.
|
|
11
|
+
INSTRUCTIONS
|
|
12
|
+
|
|
13
|
+
def initialize(base_agent:, summarization_instructions: DEFAULT_SUMMARIZATION_INSTRUCTIONS)
|
|
14
|
+
@base_agent = base_agent
|
|
15
|
+
@summarization_instructions = summarization_instructions
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def instructions
|
|
19
|
+
@summarization_instructions
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tools
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def think(messages:, listener:)
|
|
27
|
+
internal_agent.think(messages:, listener:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def internal_agent
|
|
33
|
+
@internal_agent ||= Agent.new(
|
|
34
|
+
instructions: instructions,
|
|
35
|
+
tools: tools,
|
|
36
|
+
llm_client: @base_agent.llm_client
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Agent
|
|
5
|
+
class Plan
|
|
6
|
+
PLAN_DONE_TOOL = Rixie::Tool.new(
|
|
7
|
+
name: "plan_done",
|
|
8
|
+
description: "Call this tool when the plan is complete.",
|
|
9
|
+
input_schema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
steps: {
|
|
13
|
+
type: "array",
|
|
14
|
+
items: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
title: {type: "string"},
|
|
18
|
+
description: {type: "string"}
|
|
19
|
+
},
|
|
20
|
+
required: ["title", "description"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
required: ["steps"]
|
|
25
|
+
},
|
|
26
|
+
call: ->(_args) { "Planning complete." },
|
|
27
|
+
return_direct: true
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
DEFAULT_PLANNING_INSTRUCTIONS = <<~INSTRUCTIONS
|
|
31
|
+
Make a plan to accomplish the given task. Do not output any text — call plan_done directly with the plan steps.
|
|
32
|
+
INSTRUCTIONS
|
|
33
|
+
|
|
34
|
+
def initialize(base_agent:, planning_instructions: DEFAULT_PLANNING_INSTRUCTIONS)
|
|
35
|
+
@base_agent = base_agent
|
|
36
|
+
@planning_instructions = planning_instructions
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def instructions
|
|
40
|
+
[@base_agent.instructions, @planning_instructions].join("\n\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def tools
|
|
44
|
+
@base_agent.tools + [PLAN_DONE_TOOL]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def think(messages:, listener:)
|
|
48
|
+
internal_agent.think(messages:, listener:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def internal_agent
|
|
54
|
+
@internal_agent ||= Agent.new(
|
|
55
|
+
instructions: instructions,
|
|
56
|
+
tools: tools,
|
|
57
|
+
llm_client: @base_agent.llm_client
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Agent
|
|
5
|
+
class ReAct
|
|
6
|
+
DEFAULT_REACT_INSTRUCTIONS = <<~INSTRUCTIONS
|
|
7
|
+
You are operating in ReAct (Reasoning + Acting) mode.
|
|
8
|
+
|
|
9
|
+
For every step that requires a tool, FIRST output your reasoning as plain text BEFORE making the tool call. Format your reasoning as:
|
|
10
|
+
|
|
11
|
+
Thought: <one or two sentences explaining what you need to do next and why>
|
|
12
|
+
|
|
13
|
+
Then make exactly ONE tool call. Do not call multiple tools at once.
|
|
14
|
+
|
|
15
|
+
After the tool returns (an Observation), produce another Thought before the next Action, or provide the final answer if you have enough information to respond.
|
|
16
|
+
|
|
17
|
+
The reasoning trace is essential — always include a Thought before each Action.
|
|
18
|
+
INSTRUCTIONS
|
|
19
|
+
|
|
20
|
+
def initialize(base_agent:, react_instructions: DEFAULT_REACT_INSTRUCTIONS)
|
|
21
|
+
@base_agent = base_agent
|
|
22
|
+
@react_instructions = react_instructions
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def instructions
|
|
26
|
+
[@base_agent.instructions, @react_instructions].compact.reject(&:empty?).join("\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tools
|
|
30
|
+
@base_agent.tools
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def llm_client
|
|
34
|
+
@base_agent.llm_client
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def think(messages:, listener:)
|
|
38
|
+
internal_agent.think(messages:, listener:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def internal_agent
|
|
44
|
+
@internal_agent ||= Agent.new(
|
|
45
|
+
instructions: instructions,
|
|
46
|
+
tools: tools,
|
|
47
|
+
llm_client: @base_agent.llm_client,
|
|
48
|
+
parallel_tool_calls: false
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/rixie/agent.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Agent
|
|
5
|
+
Thought = Data.define(:type, :content, :tool_calls, :tool_results) do
|
|
6
|
+
def tool_call? = type == :tool_call
|
|
7
|
+
def finish? = type == :finish
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
ThinkResult = Data.define(:content, :thoughts)
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_STEPS = 10
|
|
13
|
+
|
|
14
|
+
attr_reader :instructions, :tools, :llm_client, :parallel_tool_calls
|
|
15
|
+
|
|
16
|
+
def initialize(instructions:, llm_client:, tools: [], max_steps: nil, parallel_tool_calls: false)
|
|
17
|
+
@instructions = instructions
|
|
18
|
+
@tools = tools
|
|
19
|
+
@max_steps = max_steps || DEFAULT_MAX_STEPS
|
|
20
|
+
@parallel_tool_calls = parallel_tool_calls
|
|
21
|
+
@tool_executor = ToolExecutor.new(tools: tools)
|
|
22
|
+
@llm_client = llm_client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_llm_client(llm_client)
|
|
26
|
+
Agent.new(
|
|
27
|
+
instructions: @instructions,
|
|
28
|
+
tools: @tools,
|
|
29
|
+
max_steps: @max_steps,
|
|
30
|
+
llm_client: llm_client,
|
|
31
|
+
parallel_tool_calls: @parallel_tool_calls
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def think(messages:, listener:)
|
|
36
|
+
thoughts = []
|
|
37
|
+
tool_call_count = 0
|
|
38
|
+
step_count = 0
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
step_count += 1
|
|
42
|
+
listener.emit(Event::LlmCallStart.new(step_count: step_count))
|
|
43
|
+
thought = llm_call(messages:) { |event| listener.emit(event) }
|
|
44
|
+
|
|
45
|
+
if thought.tool_call?
|
|
46
|
+
raise MaxStepsExceededError, "Max steps (#{@max_steps}) exceeded" if tool_call_count >= @max_steps
|
|
47
|
+
tool_call_count += 1
|
|
48
|
+
results = call_thought_tools(
|
|
49
|
+
on_start: ->(tc) { listener.emit(Event::ToolCallStart.new(tool_call: tc)) },
|
|
50
|
+
thought: thought,
|
|
51
|
+
on_end: ->(tc, result) { listener.emit(Event::ToolCallEnd.new(tool_call: tc, result: result)) },
|
|
52
|
+
parallel: @parallel_tool_calls
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
listener.emit(Event::ToolCallsCompleted.new(tool_calls: thought.tool_calls, tool_results: results))
|
|
56
|
+
|
|
57
|
+
thought = record_thought(thoughts, thought, results)
|
|
58
|
+
append_thought_messages(messages, thought)
|
|
59
|
+
|
|
60
|
+
if @tool_executor.return_direct?(thought.tool_calls)
|
|
61
|
+
listener.emit(Event::Finished.new(content: nil))
|
|
62
|
+
return ThinkResult.new(content: nil, thoughts: thoughts)
|
|
63
|
+
end
|
|
64
|
+
elsif thought.finish?
|
|
65
|
+
thoughts << thought
|
|
66
|
+
listener.emit(Event::ThoughtCompleted.new(thought: thought))
|
|
67
|
+
listener.emit(Event::Finished.new(content: thought.content))
|
|
68
|
+
return ThinkResult.new(content: thought.content, thoughts: thoughts)
|
|
69
|
+
else
|
|
70
|
+
raise Rixie::AgentError, "Unknown thought type: #{thought.type.inspect}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def call_thought_tools(on_start:, thought:, on_end:, parallel:)
|
|
78
|
+
thought.tool_calls.each { |tc| on_start.call(tc) }
|
|
79
|
+
results = parallel ? concurrent_map(thought.tool_calls) { |tc| @tool_executor.execute(tc) } : thought.tool_calls.map { |tc| @tool_executor.execute(tc) }
|
|
80
|
+
thought.tool_calls.zip(results).each { |tc, result| on_end.call(tc, result) }
|
|
81
|
+
results
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def concurrent_map(items, &block)
|
|
85
|
+
# Capture exceptions as values inside each thread so no thread dies with an unhandled exception.
|
|
86
|
+
# This prevents stderr noise from Thread.report_on_exception and ensures all threads are joined
|
|
87
|
+
# before raising. Ruby has no safe cancellation, so we always wait for every thread to finish.
|
|
88
|
+
threads = items.map do |item|
|
|
89
|
+
Thread.new do
|
|
90
|
+
{ok: block.call(item)}
|
|
91
|
+
rescue => e
|
|
92
|
+
{err: e}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
outcomes = threads.map(&:value)
|
|
96
|
+
first_error = outcomes.find { |o| o.key?(:err) }&.fetch(:err)
|
|
97
|
+
raise first_error if first_error
|
|
98
|
+
outcomes.map { |o| o[:ok] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record_thought(thoughts, thought, results)
|
|
102
|
+
thought = thought.with(tool_results: results)
|
|
103
|
+
thoughts << thought
|
|
104
|
+
thought
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def append_thought_messages(messages, thought)
|
|
108
|
+
messages << Message::Assistant.new(content: thought.content, tool_calls: thought.tool_calls)
|
|
109
|
+
thought.tool_results.each { |r| messages << Message::Tool.new(tool_call_id: r.tool_call_id, content: r.content) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def llm_call(messages:, &on_event)
|
|
113
|
+
response = @llm_client.call(messages, tools: @tool_executor.definitions) { |event| on_event.call(event) }
|
|
114
|
+
raise LLM::ResponseTruncatedError, "LLM response truncated (finish_reason=length)" if response.finish_reason == "length"
|
|
115
|
+
if response.has_tool_calls?
|
|
116
|
+
Thought.new(type: :tool_call, content: response.content, tool_calls: response.tool_calls, tool_results: nil)
|
|
117
|
+
else
|
|
118
|
+
Thought.new(type: :finish, content: response.content, tool_calls: [], tool_results: nil)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(renderer:)
|
|
8
|
+
@renderer = renderer
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def name
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(arg, cli:)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def complete(input)
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :renderer
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Compress < Base
|
|
7
|
+
def name = "compress"
|
|
8
|
+
|
|
9
|
+
def description = "Compress conversation context into a summary (optionally keep N recent entries)"
|
|
10
|
+
|
|
11
|
+
def call(arg, cli:)
|
|
12
|
+
keep_recent = parse_keep_recent(arg)
|
|
13
|
+
return renderer.error("Invalid argument: expected a non-negative integer") if keep_recent.nil?
|
|
14
|
+
|
|
15
|
+
if cli.current_context_length.zero?
|
|
16
|
+
renderer.info("Context", "Already empty, nothing to compress")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
before_size = cli.current_context_size
|
|
21
|
+
renderer.start_spinner
|
|
22
|
+
cli.compress!(keep_recent: keep_recent)
|
|
23
|
+
renderer.stop_spinner
|
|
24
|
+
|
|
25
|
+
after_size = cli.current_context_size
|
|
26
|
+
if after_size >= before_size
|
|
27
|
+
renderer.info("Notice", "Compression did not reduce context size (~#{before_size} → ~#{after_size} tokens). Context may be too small to benefit from compression.")
|
|
28
|
+
else
|
|
29
|
+
renderer.success("Compressed ~#{before_size} → ~#{after_size} tokens")
|
|
30
|
+
end
|
|
31
|
+
rescue => e
|
|
32
|
+
renderer.stop_spinner
|
|
33
|
+
renderer.error(e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse_keep_recent(arg)
|
|
39
|
+
return 0 if arg.nil? || arg.strip.empty?
|
|
40
|
+
|
|
41
|
+
n = Integer(arg.strip)
|
|
42
|
+
(n >= 0) ? n : nil
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Context < Base
|
|
7
|
+
def name = "context"
|
|
8
|
+
|
|
9
|
+
def description = "Show current context size"
|
|
10
|
+
|
|
11
|
+
def call(_arg, cli:)
|
|
12
|
+
renderer.info("Context size", "~#{cli.current_context_size} tokens")
|
|
13
|
+
renderer.info("Entries", cli.current_context_length.to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Help < Base
|
|
7
|
+
def name = "help"
|
|
8
|
+
|
|
9
|
+
def description = "Show available commands"
|
|
10
|
+
|
|
11
|
+
def call(_arg, cli:)
|
|
12
|
+
renderer.heading("Commands:")
|
|
13
|
+
cli.commands.each do |cmd|
|
|
14
|
+
renderer.text("#{renderer.accent("/#{cmd.name}")} — #{cmd.description}")
|
|
15
|
+
end
|
|
16
|
+
renderer.text("#{renderer.accent("exit")} — Quit")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Model < Base
|
|
7
|
+
def name = "model"
|
|
8
|
+
|
|
9
|
+
def description = "Switch LLM model"
|
|
10
|
+
|
|
11
|
+
def call(arg, cli:)
|
|
12
|
+
if arg && !arg.empty?
|
|
13
|
+
new_model = arg.strip
|
|
14
|
+
return if new_model.empty?
|
|
15
|
+
|
|
16
|
+
cli.switch_model(new_model)
|
|
17
|
+
renderer.success("Model set to #{renderer.bold(new_model)}")
|
|
18
|
+
else
|
|
19
|
+
renderer.info("Current", cli.current_model)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Strategy < Base
|
|
7
|
+
STRATEGIES = {
|
|
8
|
+
"simple" => Rixie::Strategy::Simple,
|
|
9
|
+
"plan-execute" => Rixie::Strategy::PlanExecute,
|
|
10
|
+
"re-act" => Rixie::Strategy::ReAct
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def name = "strategy"
|
|
14
|
+
|
|
15
|
+
def description = "Switch execution strategy"
|
|
16
|
+
|
|
17
|
+
def call(arg, cli:)
|
|
18
|
+
if arg && !arg.empty?
|
|
19
|
+
set_strategy(arg.strip, cli:)
|
|
20
|
+
else
|
|
21
|
+
renderer.info("Current", cli.strategy_name)
|
|
22
|
+
renderer.heading("Available:")
|
|
23
|
+
renderer.list(STRATEGIES.keys, selected: cli.strategy_name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def complete(input)
|
|
28
|
+
arg = input.delete_prefix("/strategy ")
|
|
29
|
+
STRATEGIES.keys.select { |s| s.start_with?(arg) }.map { |s| "/strategy #{s}" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve(name)
|
|
33
|
+
STRATEGIES[name]&.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def set_strategy(name, cli:)
|
|
39
|
+
if STRATEGIES.key?(name)
|
|
40
|
+
cli.strategy_name = name
|
|
41
|
+
renderer.success("Strategy set to #{renderer.bold(name)}")
|
|
42
|
+
else
|
|
43
|
+
renderer.error("Unknown strategy: #{name}")
|
|
44
|
+
renderer.text("Available: #{STRATEGIES.keys.join(", ")}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|