rubyrana 0.1.0 → 0.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +3 -5
  4. data/docs/INDEX.md +16 -0
  5. data/docs/{USAGE.md → guides/USAGE.md} +7 -0
  6. data/docs/operations/DEV_WORKFLOW.md +16 -0
  7. data/docs/{RELEASE.md → operations/RELEASE.md} +1 -1
  8. data/docs/roadmap/TASKS.md +46 -0
  9. data/docs/roadmap/TODO.md +23 -0
  10. data/examples/README.md +9 -0
  11. data/lib/rubyrana/a2a/agent.rb +119 -0
  12. data/lib/rubyrana/a2a/client.rb +105 -0
  13. data/lib/rubyrana/a2a/converters.rb +91 -0
  14. data/lib/rubyrana/a2a/types.rb +27 -0
  15. data/lib/rubyrana/agent.rb +350 -43
  16. data/lib/rubyrana/config.rb +45 -1
  17. data/lib/rubyrana/errors.rb +2 -0
  18. data/lib/rubyrana/event_loop/events.rb +15 -0
  19. data/lib/rubyrana/event_loop/runner.rb +117 -0
  20. data/lib/rubyrana/hooks/base.rb +12 -0
  21. data/lib/rubyrana/hooks/events.rb +16 -0
  22. data/lib/rubyrana/hooks/provider.rb +11 -0
  23. data/lib/rubyrana/hooks/registry.rb +77 -0
  24. data/lib/rubyrana/limits/rate_limiter.rb +35 -0
  25. data/lib/rubyrana/limits/semaphore.rb +30 -0
  26. data/lib/rubyrana/memory/strategy.rb +37 -0
  27. data/lib/rubyrana/multi_agent.rb +43 -0
  28. data/lib/rubyrana/multiagent/router.rb +33 -0
  29. data/lib/rubyrana/providers/anthropic.rb +88 -47
  30. data/lib/rubyrana/providers/base.rb +51 -0
  31. data/lib/rubyrana/retry/circuit_breaker.rb +73 -0
  32. data/lib/rubyrana/retry/policy.rb +39 -0
  33. data/lib/rubyrana/session/context.rb +14 -0
  34. data/lib/rubyrana/session/file_repository.rb +157 -0
  35. data/lib/rubyrana/session/redis_repository.rb +157 -0
  36. data/lib/rubyrana/session/repository.rb +180 -0
  37. data/lib/rubyrana/session/types.rb +9 -0
  38. data/lib/rubyrana/telemetry/exporter.rb +11 -0
  39. data/lib/rubyrana/telemetry/jsonl_exporter.rb +22 -0
  40. data/lib/rubyrana/telemetry/metrics.rb +64 -0
  41. data/lib/rubyrana/telemetry/tracer.rb +41 -0
  42. data/lib/rubyrana/tool.rb +14 -0
  43. data/lib/rubyrana/tools/structured_output/schema.rb +17 -0
  44. data/lib/rubyrana/tools/structured_output/tool.rb +40 -0
  45. data/lib/rubyrana/types/agent_result.rb +17 -0
  46. data/lib/rubyrana/types/interrupt.rb +7 -0
  47. data/lib/rubyrana/types/message.rb +7 -0
  48. data/lib/rubyrana/types/tool_result.rb +7 -0
  49. data/lib/rubyrana/types/tool_use.rb +7 -0
  50. data/lib/rubyrana/version.rb +1 -1
  51. data/lib/rubyrana.rb +34 -0
  52. metadata +66 -18
  53. /data/docs/{CHECKLIST.md → operations/CHECKLIST.md} +0 -0
  54. /data/{REPORT.md → docs/reports/REPORT.md} +0 -0
  55. /data/examples/{mcp.rb → advanced/mcp.rb} +0 -0
  56. /data/examples/{tools_loader.rb → advanced/tools_loader.rb} +0 -0
  57. /data/examples/{quick_start.rb → basic/quick_start.rb} +0 -0
  58. /data/examples/{streaming.rb → basic/streaming.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a23b823b6f21c5d8093c355b4c0a4fdcb8e5268b075163708d4e3d0042e556b0
4
- data.tar.gz: f3b7ecd4ff79260a1d24fde0c8028dd496a89973212c356949fee339b9e612f4
3
+ metadata.gz: 34608dd47bf0508e17e5f98c2234ea030a853dadd0dfbb22e14770a179bcd09e
4
+ data.tar.gz: 32e051b98dcea4c2246eff0c77b1f5d8b0cf5c4f7cdb2c409cead771ade22db6
5
5
  SHA512:
6
- metadata.gz: a540d2da6e926c6b9abd75761a978000785fb4b0951a95f6df641b222e34df7e62fb202013eb331f975422c7d55bea5ce7266ac667662119c3f5ce2deb1aa85d
7
- data.tar.gz: 986c64f68ea2fab6cee3851649b28aa8c8fb14356bad86aa84962e2e01748e656671773c17f5b1664b22a6827009f4828c160737f14983343ef5b5448befc691
6
+ metadata.gz: 513bf981ad9bcf68f9891c94a81c3298c30e7b2fc25936c33789e9d10fa6e951afa883f740ccedc3e016749a43d327be4b8279f626d63e1c0ea026e4419fc9ca
7
+ data.tar.gz: 025dc718c6125ef006ad486bdb47f533e6372694ea664c9cd1dc3697773c02bfaf6837699c901e335c5d907c6e552774fc134cd5e7efa6b6d920f39d36d860e7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+ - Add structured output support with retries and event loop integration
5
+ - Expand hooks taxonomy and provider registration
6
+ - Introduce session repository layer with in-memory, file, and Redis adapters
7
+ - Add A2A agent implementation and types
8
+ - Add event loop events, interrupts, and richer agent results
9
+ - Add multi-agent graph execution helper
10
+ - Reorganize docs/examples and move build artifacts/vendor snapshots
11
+
12
+ ## 0.1.1
13
+ - Update gemspec homepage
14
+
3
15
  ## 0.1.0
4
16
  - Initial MVP scaffolding
5
17
  - Agent loop with tool calling
data/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <div align="center">
10
10
  <img alt="Gem version" src="https://img.shields.io/gem/v/rubyrana" />
11
- <img alt="Ruby" src="https://img.shields.io/badge/ruby-%3E%3D%203.1-brightgreen" />
11
+ <img alt="Ruby" src="https://img.shields.io/badge/ruby-%3E%3D%203.4-brightgreen" />
12
12
  <img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue" />
13
13
  </div>
14
14
 
@@ -165,10 +165,8 @@ puts agent.call("Explain agentic workflows in simple terms")
165
165
 
166
166
  ## Documentation
167
167
 
168
- - Getting Started
169
- - Core Concepts
170
- - Tools & MCP
171
- - Production Deployment
168
+ - [docs/INDEX.md](docs/INDEX.md)
169
+ - [examples/README.md](examples/README.md)
172
170
 
173
171
  ## Contributing
174
172
 
data/docs/INDEX.md ADDED
@@ -0,0 +1,16 @@
1
+ # Documentation Index
2
+
3
+ ## Guides
4
+ - [docs/guides/USAGE.md](docs/guides/USAGE.md)
5
+
6
+ ## Operations
7
+ - [docs/operations/DEV_WORKFLOW.md](docs/operations/DEV_WORKFLOW.md)
8
+ - [docs/operations/RELEASE.md](docs/operations/RELEASE.md)
9
+ - [docs/operations/CHECKLIST.md](docs/operations/CHECKLIST.md)
10
+
11
+ ## Roadmap
12
+ - [docs/roadmap/TASKS.md](docs/roadmap/TASKS.md)
13
+ - [docs/roadmap/TODO.md](docs/roadmap/TODO.md)
14
+
15
+ ## Reports
16
+ - [docs/reports/REPORT.md](docs/reports/REPORT.md)
@@ -46,6 +46,13 @@ Load tools from a directory:
46
46
 
47
47
  - agent = Rubyrana::Agent.new(load_tools_from: "./tools")
48
48
 
49
+ ## Memory Strategies
50
+
51
+ Use a rolling window to cap context size:
52
+
53
+ - strategy = Rubyrana::Memory::RollingWindow.new(max_messages: 20)
54
+ - agent = Rubyrana::Agent.new(memory_strategy: strategy)
55
+
49
56
  ## MCP (Experimental)
50
57
 
51
58
  Use the MCP client to load tools:
@@ -0,0 +1,16 @@
1
+ # Dev Workflow
2
+
3
+ ## Install
4
+ - `bundle install`
5
+
6
+ ## Run tests (fast feedback)
7
+ - `scripts/run_tests.sh`
8
+
9
+ ## Run an example
10
+ - `scripts/run_example.sh examples/quick_start.rb`
11
+
12
+ ## Suggested loop
13
+ 1) Implement a small change
14
+ 2) Run tests
15
+ 3) Run a relevant example
16
+ 4) Commit
@@ -9,7 +9,7 @@
9
9
  ## Release
10
10
  - Tag release in git (e.g., v0.1.0)
11
11
  - Build gem: `gem build rubyrana.gemspec`
12
- - Push gem: `gem push rubyrana-0.1.0.gem`
12
+ - Push gem: `gem push dist/rubyrana-0.1.0.gem`
13
13
 
14
14
  ## Post-release
15
15
  - Update any version references
@@ -0,0 +1,46 @@
1
+ # Rubyrana Full‑Fledge Roadmap
2
+
3
+ ## 1) Anthropic Depth
4
+ - Add system prompts support
5
+ - Add tool_choice controls
6
+ - Add thinking/analysis options
7
+ - Add response format tuning
8
+ - Map provider errors to friendly categories
9
+ - Expand provider tests
10
+
11
+ ## 2) Tooling Parity
12
+ - Add tool schema validation
13
+ - Support structured tool results
14
+ - Add tool choice policies
15
+ - Improve tool debugging and tracing
16
+ - Optional tool hot‑reload
17
+
18
+ ## 3) Memory & Context
19
+ - Context window trimming
20
+ - Summarization memory
21
+ - Token budgeting
22
+ - Memory strategy options (rolling, summary)
23
+
24
+ ## 4) Streaming Robustness
25
+ - Partial chunk handling
26
+ - Tool‑use streaming
27
+ - Usage metadata during streaming
28
+ - Streaming edge‑case tests
29
+
30
+ ## 5) Observability
31
+ - Request/trace IDs
32
+ - Metrics hooks
33
+ - Structured logs
34
+ - Provider/tool timing
35
+
36
+ ## 6) Tool Ecosystem
37
+ - Split built‑in tools into packs
38
+ - MCP tool discovery examples
39
+ - Tool pack publishing guide
40
+
41
+ ## 7) Docs & Examples
42
+ - Multi‑agent routing example
43
+ - Safety filter example
44
+ - Persistence example
45
+ - MCP web search example
46
+ - Deployment guide
@@ -0,0 +1,23 @@
1
+ # Rubyrana Roadmap (Post-MVP)
2
+
3
+ ## Core Agent Capabilities
4
+ - [x] Conversation memory and history management
5
+ - [x] Structured tool-calling semantics per provider
6
+ - [x] Native streaming for Anthropic
7
+ - [x] Token usage + cost tracking
8
+
9
+ ## Developer Experience
10
+ - [x] Rich examples (tools, MCP, streaming, providers)
11
+ - [x] Error messages and retries tuned per provider
12
+ - [x] Logging hooks and debug mode
13
+
14
+ ## Features Beyond MVP
15
+ - [x] Multi-agent orchestration
16
+ - [x] Task routing / tool selection policies
17
+ - [x] Configurable safety filters
18
+ - [x] Persistence adapters (file/redis)
19
+
20
+ ## Production Readiness
21
+ - [x] Test coverage expansion
22
+ - [x] CI enhancements (lint)
23
+ - [x] Release automation
@@ -0,0 +1,9 @@
1
+ # Examples
2
+
3
+ ## Basic
4
+ - [examples/basic/quick_start.rb](examples/basic/quick_start.rb)
5
+ - [examples/basic/streaming.rb](examples/basic/streaming.rb)
6
+
7
+ ## Advanced
8
+ - [examples/advanced/mcp.rb](examples/advanced/mcp.rb)
9
+ - [examples/advanced/tools_loader.rb](examples/advanced/tools_loader.rb)
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyrana
4
+ module A2A
5
+ class Agent
6
+ DEFAULT_TIMEOUT = 300
7
+
8
+ attr_reader :endpoint, :timeout, :name, :description
9
+
10
+ def initialize(endpoint:, name: nil, description: nil, timeout: DEFAULT_TIMEOUT, a2a_client_factory: nil, card_resolver: nil)
11
+ @endpoint = endpoint
12
+ @name = name
13
+ @description = description
14
+ @timeout = timeout
15
+ @agent_card = nil
16
+ @a2a_client_factory = a2a_client_factory
17
+ @card_resolver = card_resolver
18
+ end
19
+
20
+ def call(prompt = nil, **_kwargs)
21
+ invoke(prompt)
22
+ end
23
+
24
+ def invoke(prompt = nil, **_kwargs)
25
+ result = nil
26
+ stream(prompt) do |event|
27
+ result = event[:result] if event[:result]
28
+ end
29
+ raise RuntimeError, "No response received from A2A agent" unless result
30
+
31
+ result
32
+ end
33
+
34
+ def stream(prompt = nil, **_kwargs)
35
+ raise ArgumentError, "prompt is required" if prompt.nil?
36
+
37
+ last_event = nil
38
+ last_complete_event = nil
39
+ enumerator = _send_message(prompt)
40
+
41
+ if block_given?
42
+ enumerator.each do |event|
43
+ last_event = event
44
+ last_complete_event = event if complete_event?(event)
45
+ yield({ type: "a2a_stream", event: event })
46
+ end
47
+ final_event = last_complete_event || last_event
48
+ if final_event
49
+ result = Rubyrana::A2A::Converters.convert_response_to_agent_result(final_event)
50
+ yield({ result: result })
51
+ end
52
+ return
53
+ end
54
+
55
+ Enumerator.new do |yielder|
56
+ enumerator.each do |event|
57
+ last_event = event
58
+ last_complete_event = event if complete_event?(event)
59
+ yielder << { type: "a2a_stream", event: event }
60
+ end
61
+
62
+ final_event = last_complete_event || last_event
63
+ if final_event
64
+ result = Rubyrana::A2A::Converters.convert_response_to_agent_result(final_event)
65
+ yielder << { result: result }
66
+ end
67
+ end
68
+ end
69
+
70
+ def get_agent_card
71
+ return @agent_card if @agent_card
72
+
73
+ resolver = @card_resolver || Rubyrana::A2A::CardResolver.new(base_url: endpoint)
74
+ @agent_card = resolver.get_agent_card
75
+
76
+ @name ||= @agent_card.name
77
+ @description ||= @agent_card.description
78
+
79
+ @agent_card
80
+ end
81
+
82
+ private
83
+
84
+ def _send_message(prompt)
85
+ message = Rubyrana::A2A::Converters.convert_input_to_message(prompt)
86
+ client = a2a_client
87
+ client.send_message(message)
88
+ end
89
+
90
+ def a2a_client
91
+ agent_card = get_agent_card
92
+ factory = @a2a_client_factory || Rubyrana::A2A::ClientFactory.new
93
+ factory.create(agent_card)
94
+ end
95
+
96
+ def complete_event?(event)
97
+ return true if event.is_a?(Rubyrana::A2A::Message)
98
+
99
+ if event.is_a?(Array) && event.length == 2
100
+ task, update_event = event
101
+ return true if update_event.nil?
102
+
103
+ if update_event.is_a?(Rubyrana::A2A::TaskArtifactUpdateEvent)
104
+ return update_event.last_chunk unless update_event.last_chunk.nil?
105
+ return false
106
+ end
107
+
108
+ if update_event.is_a?(Rubyrana::A2A::TaskStatusUpdateEvent)
109
+ return update_event.status.state == "completed" if update_event.status&.state
110
+ end
111
+
112
+ return false
113
+ end
114
+
115
+ false
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Rubyrana
7
+ module A2A
8
+ class CardResolver
9
+ def initialize(base_url:, http_client: nil)
10
+ @base_url = base_url
11
+ @http_client = http_client
12
+ end
13
+
14
+ def get_agent_card
15
+ uri = URI.join(@base_url, "/.well-known/agent-card")
16
+ response = http_client.get(uri)
17
+ raise Rubyrana::ProviderError, "A2A agent card request failed (status #{response.code})" unless response.is_a?(Net::HTTPSuccess)
18
+
19
+ body = JSON.parse(response.body)
20
+ Rubyrana::A2A::AgentCard.new(
21
+ name: body["name"],
22
+ description: body["description"],
23
+ url: body["url"],
24
+ version: body["version"],
25
+ capabilities: body["capabilities"],
26
+ default_input_modes: body["default_input_modes"] || [],
27
+ default_output_modes: body["default_output_modes"] || [],
28
+ skills: body["skills"] || []
29
+ )
30
+ rescue JSON::ParserError => e
31
+ raise Rubyrana::ProviderError, e.message
32
+ end
33
+
34
+ private
35
+
36
+ def http_client
37
+ @http_client ||= Net::HTTP
38
+ end
39
+ end
40
+
41
+ class ClientFactory
42
+ def initialize(http_client: nil, streaming: true)
43
+ @http_client = http_client
44
+ @streaming = streaming
45
+ end
46
+
47
+ def create(agent_card)
48
+ Client.new(agent_card.url, http_client: @http_client, streaming: @streaming)
49
+ end
50
+ end
51
+
52
+ class Client
53
+ def initialize(base_url, http_client: nil, streaming: true)
54
+ @base_url = base_url
55
+ @http_client = http_client || Net::HTTP
56
+ @streaming = streaming
57
+ end
58
+
59
+ def send_message(message)
60
+ response = post_message(message)
61
+ parsed = parse_message_response(response)
62
+
63
+ Enumerator.new do |yielder|
64
+ yielder << parsed
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def post_message(message)
71
+ uri = URI.join(@base_url, "/messages")
72
+ request = Net::HTTP::Post.new(uri)
73
+ request["Content-Type"] = "application/json"
74
+ request.body = JSON.dump({ message: serialize_message(message) })
75
+
76
+ @http_client.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
77
+ http.request(request)
78
+ end
79
+ end
80
+
81
+ def parse_message_response(response)
82
+ raise Rubyrana::ProviderError, "A2A message request failed (status #{response.code})" unless response.is_a?(Net::HTTPSuccess)
83
+
84
+ body = JSON.parse(response.body)
85
+ message = body["message"] || body
86
+ parts = Array(message["parts"]).map { |part| Rubyrana::A2A::Part.new(kind: part["kind"], text: part["text"]) }
87
+ Rubyrana::A2A::Message.new(
88
+ message_id: message["message_id"] || SecureRandom.uuid,
89
+ role: message["role"] || "agent",
90
+ parts: parts
91
+ )
92
+ rescue JSON::ParserError => e
93
+ raise Rubyrana::ProviderError, e.message
94
+ end
95
+
96
+ def serialize_message(message)
97
+ {
98
+ message_id: message.message_id,
99
+ role: message.role,
100
+ parts: message.parts.map { |part| { kind: part.kind, text: part.text } }
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyrana
4
+ module A2A
5
+ module Converters
6
+ module_function
7
+
8
+ def convert_input_to_message(prompt)
9
+ return prompt if prompt.is_a?(Rubyrana::A2A::Message)
10
+
11
+ if prompt.is_a?(Hash)
12
+ role = prompt[:role] || prompt["role"] || "user"
13
+ content = prompt[:content] || prompt["content"] || ""
14
+ return build_message(role, content)
15
+ end
16
+
17
+ if prompt.is_a?(Array)
18
+ return build_message("user", prompt)
19
+ end
20
+
21
+ build_message("user", prompt.to_s)
22
+ end
23
+
24
+ def convert_response_to_agent_result(event)
25
+ message = extract_message(event)
26
+ text = extract_text_from_message(message)
27
+ Rubyrana::Types::AgentResult.new(
28
+ text: text,
29
+ message: message,
30
+ stop_reason: "end_turn",
31
+ tool_results: [],
32
+ tool_uses: [],
33
+ interrupts: []
34
+ )
35
+ end
36
+
37
+ def build_message(role, content)
38
+ parts = normalize_parts(content)
39
+ Rubyrana::A2A::Message.new(
40
+ message_id: SecureRandom.uuid,
41
+ role: role,
42
+ parts: parts
43
+ )
44
+ end
45
+
46
+ def normalize_parts(content)
47
+ return content.map { |item| to_part(item) } if content.is_a?(Array)
48
+
49
+ [to_part(content)]
50
+ end
51
+
52
+ def to_part(item)
53
+ return item if item.is_a?(Rubyrana::A2A::Part) || item.is_a?(Rubyrana::A2A::TextPart)
54
+
55
+ if item.is_a?(Hash)
56
+ kind = item[:kind] || item["kind"] || "text"
57
+ text = item[:text] || item["text"] || item[:content] || item["content"] || item.to_s
58
+ return Rubyrana::A2A::Part.new(kind: kind, text: text)
59
+ end
60
+
61
+ Rubyrana::A2A::TextPart.new(kind: "text", text: item.to_s)
62
+ end
63
+
64
+ def extract_message(event)
65
+ return message_to_hash(event) if event.is_a?(Rubyrana::A2A::Message)
66
+
67
+ if event.is_a?(Array) && event.length == 2
68
+ task = event[0]
69
+ return message_to_hash(task.message) if task.respond_to?(:message) && task.message
70
+ end
71
+
72
+ if event.is_a?(Hash)
73
+ return event if event.key?(:role) || event.key?("role")
74
+ return event[:message] || event["message"] if event[:message] || event["message"]
75
+ end
76
+
77
+ { role: "assistant", content: [{ text: event.to_s }] }
78
+ end
79
+
80
+ def message_to_hash(message)
81
+ content = message.parts.to_a.map { |part| { text: part.text } }
82
+ { role: message.role, content: content }
83
+ end
84
+
85
+ def extract_text_from_message(message)
86
+ content = message[:content] || message["content"] || []
87
+ content.map { |item| item[:text] || item["text"] }.compact.join
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyrana
4
+ module A2A
5
+ AgentCard = Struct.new(
6
+ :name,
7
+ :description,
8
+ :url,
9
+ :version,
10
+ :capabilities,
11
+ :default_input_modes,
12
+ :default_output_modes,
13
+ :skills,
14
+ keyword_init: true
15
+ )
16
+
17
+ TextPart = Struct.new(:kind, :text, keyword_init: true)
18
+ Part = Struct.new(:kind, :text, keyword_init: true)
19
+
20
+ Message = Struct.new(:message_id, :role, :parts, keyword_init: true)
21
+
22
+ Task = Struct.new(:id, :status, :message, keyword_init: true)
23
+ TaskStatus = Struct.new(:state, keyword_init: true)
24
+ TaskStatusUpdateEvent = Struct.new(:status, keyword_init: true)
25
+ TaskArtifactUpdateEvent = Struct.new(:last_chunk, keyword_init: true)
26
+ end
27
+ end