ask-agent 0.12.0 → 0.14.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 +27 -0
- data/README.md +53 -2
- data/lib/ask/agent/extensions/permission_gate.rb +5 -62
- data/lib/ask/agent/extensions/permissions.rb +86 -0
- data/lib/ask/agent/loop.rb +6 -2
- data/lib/ask/agent/middleware/model_fallback.rb +110 -0
- data/lib/ask/agent/middleware/pipeline.rb +2 -1
- data/lib/ask/agent/session.rb +45 -30
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c80f27cbdec5b3808533dba4861762e19e4ef81c084be099a4a7eb0b2169170e
|
|
4
|
+
data.tar.gz: 34e5388035a8f216df468039bf8c7e304296d7661832e8109df4d76fbb527193
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb1bf1b6aac7a61a650291f9dcabd781c0a664cb09d97c7f2cc2fcdcfaa035b71adef2c423ebacfaae0de04d0524b4dff649f503921006837e0644d9a9f86615
|
|
7
|
+
data.tar.gz: d718885adebbaf33f2b9a2b95704968494d14e3080746a58da37c9518af475ac09103357014e5bea98339d56150ce63a162c6ac9a9c8ae4cffa07f13716b8793
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
## [0.14.0] — 2026-07-23
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **`state:` keyword on Session** — Accepts any `Ask::State::Adapter` directly. Sessions persist conversation state, tool results, and metadata. Replaces `persistence:` keyword (still supported for backward compatibility).
|
|
6
|
+
- **Per-turn persistence** — Session now persists after every LLM turn, not just at the end of `run()`. Mid-session crashes no longer lose progress.
|
|
7
|
+
- **`Session.load` restores `@messages`** — Previously `session.messages` returned `nil` after loading. Now it's populated from the restored chat messages.
|
|
8
|
+
- **`Ask::State::Adapter#clear`** — Abstract method added to the adapter contract. Memory adapter implements it.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Session behind the scenes now uses `@state.set`/`@state.get`/`@state.delete`** instead of the old `@persistence.save`/`@persistence.load`/`@persistence.delete`. Custom adapters must respond to `set`/`get`/`delete`.
|
|
13
|
+
|
|
14
|
+
## [0.13.0] — 2026-07-23
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **ModelFallback middleware** — Switches to a fallback model+provider when the primary LLM call fails with a rate limit, server error, or service unavailable. Supports static and dynamic (lambda-based) fallback lists. Credentials resolve automatically via `Ask::Auth`.
|
|
19
|
+
- Static fallbacks: ordered list of `{ model:, provider: }` hashes
|
|
20
|
+
- Dynamic fallbacks: lambda receiving `(error, request)` returning the list
|
|
21
|
+
- Custom eligible errors: configure which errors trigger fallback
|
|
22
|
+
- Each fallback builds its own provider instance with resolved credentials
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `Pipeline::KNOWN_MIDDLEWARES` now includes `:model_fallback`.
|
|
27
|
+
|
|
1
28
|
## [0.12.0] — 2026-07-22
|
|
2
29
|
|
|
3
30
|
### Added
|
data/README.md
CHANGED
|
@@ -60,13 +60,13 @@ end
|
|
|
60
60
|
|
|
61
61
|
Opt-in safety modules:
|
|
62
62
|
|
|
63
|
-
- **
|
|
63
|
+
- **Permissions** — Access control for tools. Supports named access modes (`:full_access`, `:read_only`, `:ask_before_changes`) or custom blocked-tool lists.
|
|
64
64
|
- **RateLimiter** — Prevent runaway tool calls (configurable per-minute and per-turn limits)
|
|
65
65
|
- **AuditLog** — Immutable, append-only log of every tool call
|
|
66
66
|
|
|
67
67
|
```ruby
|
|
68
68
|
extensions = [
|
|
69
|
-
Ask::Agent::Extensions::
|
|
69
|
+
Ask::Agent::Extensions::Permissions.new(mode: :read_only),
|
|
70
70
|
Ask::Agent::Extensions::RateLimiter.new(max_calls_per_minute: 30),
|
|
71
71
|
Ask::Agent::Extensions::AuditLog.new(path: "agent.log")
|
|
72
72
|
]
|
|
@@ -81,6 +81,57 @@ session = Ask::Agent::Session.new(
|
|
|
81
81
|
)
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
## Middleware
|
|
85
|
+
|
|
86
|
+
Wrapping LLM provider calls with cross-cutting behavior:
|
|
87
|
+
|
|
88
|
+
- **RetryOnFailure** — Retry on rate limits and server errors with exponential backoff
|
|
89
|
+
- **ModelFallback** — Switch to a fallback model+provider on transient errors
|
|
90
|
+
- **LogCalls** — Log every LLM provider call
|
|
91
|
+
- **DefaultSettings** — Inject default generation parameters
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Ask::Agent.configure do |c|
|
|
95
|
+
c.middleware.use :retry_on_failure, max_retries: 3
|
|
96
|
+
c.middleware.use :model_fallback, fallbacks: [
|
|
97
|
+
{ model: "claude-sonnet-4", provider: :anthropic },
|
|
98
|
+
{ model: "gemini-2.0-flash", provider: :google }
|
|
99
|
+
]
|
|
100
|
+
c.middleware.use :log_calls, logger: Rails.logger
|
|
101
|
+
c.middleware.use :default_settings, temperature: 0.7
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### ModelFallback
|
|
106
|
+
|
|
107
|
+
When the primary LLM is overloaded or down, `ModelFallback` transparently switches to a backup model+provider. Credentials for each provider are resolved automatically.
|
|
108
|
+
|
|
109
|
+
**Static fallbacks** — ordered list tried in sequence:
|
|
110
|
+
```ruby
|
|
111
|
+
c.middleware.use :model_fallback, fallbacks: [
|
|
112
|
+
{ model: "claude-sonnet-4", provider: :anthropic },
|
|
113
|
+
{ model: "gemini-2.0-flash", provider: :google }
|
|
114
|
+
]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Dynamic fallbacks** — lambda that receives the error and request:
|
|
118
|
+
```ruby
|
|
119
|
+
c.middleware.use :model_fallback, fallbacks: ->(error, request) {
|
|
120
|
+
if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
|
|
121
|
+
[{ model: "claude-sonnet-4", provider: :anthropic }] # long-context
|
|
122
|
+
else
|
|
123
|
+
[{ model: "gpt-4o-mini", provider: :openai }] # cheaper
|
|
124
|
+
end
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Custom eligible errors** — by default rate limits, server errors, and service unavailable:
|
|
129
|
+
```ruby
|
|
130
|
+
c.middleware.use :model_fallback,
|
|
131
|
+
fallbacks: [{ model: "claude-sonnet-4", provider: :anthropic }],
|
|
132
|
+
eligible_errors: [Ask::RateLimitError, Ask::ServerError]
|
|
133
|
+
```
|
|
134
|
+
|
|
84
135
|
## Configuration
|
|
85
136
|
|
|
86
137
|
```ruby
|
|
@@ -1,70 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Legacy alias — PermissionGate has been renamed to Permissions.
|
|
4
|
+
# This file will be removed in the next major version.
|
|
5
|
+
require_relative "permissions"
|
|
6
|
+
|
|
3
7
|
module Ask
|
|
4
8
|
module Agent
|
|
5
9
|
module Extensions
|
|
6
|
-
|
|
7
|
-
DEFAULT_TOOLS = %i[write edit bash destroy].freeze
|
|
8
|
-
|
|
9
|
-
def initialize(blocked_tools: DEFAULT_TOOLS, timeout: nil)
|
|
10
|
-
@blocked_tools = Array(blocked_tools).map(&:to_sym)
|
|
11
|
-
@timeout = timeout
|
|
12
|
-
@pending = {}
|
|
13
|
-
@mutex = Mutex.new
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def before_tool_call(tool_call, _context)
|
|
17
|
-
return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
|
|
18
|
-
|
|
19
|
-
if approved?(tool_call)
|
|
20
|
-
{ action: :proceed }
|
|
21
|
-
else
|
|
22
|
-
request_approval(tool_call)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def approve(tool_call_id)
|
|
27
|
-
@mutex.synchronize do
|
|
28
|
-
entry = @pending[tool_call_id]
|
|
29
|
-
return false unless entry
|
|
30
|
-
entry[:approved] = true
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def pending_approvals
|
|
35
|
-
@mutex.synchronize { @pending.values.reject { |e| e[:approved] } }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
def approved?(tool_call)
|
|
41
|
-
@mutex.synchronize do
|
|
42
|
-
key = tool_call.id
|
|
43
|
-
entry = @pending[key]
|
|
44
|
-
return false unless entry
|
|
45
|
-
|
|
46
|
-
if @timeout && (Time.now - entry[:created_at]) > @timeout
|
|
47
|
-
@pending.delete(key)
|
|
48
|
-
return false
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
entry[:approved]
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def request_approval(tool_call)
|
|
56
|
-
@mutex.synchronize do
|
|
57
|
-
@pending[tool_call.id] = {
|
|
58
|
-
tool_call: tool_call,
|
|
59
|
-
approved: false,
|
|
60
|
-
created_at: Time.now
|
|
61
|
-
}
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
warn "[PermissionGate] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
|
|
65
|
-
{ action: :block, reason: "Tool '#{tool_call.name}' requires approval" }
|
|
66
|
-
end
|
|
67
|
-
end
|
|
10
|
+
PermissionGate = Permissions
|
|
68
11
|
end
|
|
69
12
|
end
|
|
70
13
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Extensions
|
|
6
|
+
class Permissions
|
|
7
|
+
DEFAULT_TOOLS = %i[write edit bash destroy].freeze
|
|
8
|
+
|
|
9
|
+
ACCESS_MODES = {
|
|
10
|
+
full_access: { blocked_tools: [] }.freeze,
|
|
11
|
+
ask_before_changes: { blocked_tools: DEFAULT_TOOLS }.freeze,
|
|
12
|
+
read_only: { blocked_tools: %i[write edit bash destroy] }.freeze
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(mode: nil, blocked_tools: nil, timeout: nil)
|
|
16
|
+
@mode = mode
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@pending = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
|
|
21
|
+
@blocked_tools = if mode
|
|
22
|
+
config = ACCESS_MODES[mode]
|
|
23
|
+
raise ArgumentError, "Unknown access mode: #{mode.inspect}. Valid: #{ACCESS_MODES.keys.join(', ')}" unless config
|
|
24
|
+
config[:blocked_tools].dup
|
|
25
|
+
elsif blocked_tools
|
|
26
|
+
Array(blocked_tools).map(&:to_sym)
|
|
27
|
+
else
|
|
28
|
+
DEFAULT_TOOLS.dup
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def before_tool_call(tool_call, _context)
|
|
33
|
+
return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
|
|
34
|
+
|
|
35
|
+
if approved?(tool_call)
|
|
36
|
+
{ action: :proceed }
|
|
37
|
+
else
|
|
38
|
+
request_approval(tool_call)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def approve(tool_call_id)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
entry = @pending[tool_call_id]
|
|
45
|
+
return false unless entry
|
|
46
|
+
entry[:approved] = true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pending_approvals
|
|
51
|
+
@mutex.synchronize { @pending.values.reject { |e| e[:approved] } }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def approved?(tool_call)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
key = tool_call.id
|
|
59
|
+
entry = @pending[key]
|
|
60
|
+
return false unless entry
|
|
61
|
+
|
|
62
|
+
if @timeout && (Time.now - entry[:created_at]) > @timeout
|
|
63
|
+
@pending.delete(key)
|
|
64
|
+
return false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
entry[:approved]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request_approval(tool_call)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@pending[tool_call.id] = {
|
|
74
|
+
tool_call: tool_call,
|
|
75
|
+
approved: false,
|
|
76
|
+
created_at: Time.now
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
warn "[Permissions] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
|
|
81
|
+
{ action: :block, reason: "Tool '#{tool_call.name}' requires approval" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Ask
|
|
|
17
17
|
@max_consecutive_tool_turns = max_consecutive_tool_turns
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil)
|
|
20
|
+
def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil)
|
|
21
21
|
raise MaxTurnsExceeded if @turn_count >= @max_turns
|
|
22
22
|
|
|
23
23
|
event_emitter.emit(Events::TurnStart.new)
|
|
@@ -103,6 +103,9 @@ module Ask
|
|
|
103
103
|
compactor.run(event_emitter: event_emitter)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# Persist after each turn so mid-session crashes don't lose progress
|
|
107
|
+
persist&.call
|
|
108
|
+
|
|
106
109
|
raise MaxTurnsExceeded if @turn_count >= @max_turns
|
|
107
110
|
|
|
108
111
|
# Recursive call — LLM processes tool results
|
|
@@ -114,7 +117,8 @@ module Ask
|
|
|
114
117
|
compactor: compactor,
|
|
115
118
|
hooks: hooks,
|
|
116
119
|
event_emitter: event_emitter,
|
|
117
|
-
session_id: session_id
|
|
120
|
+
session_id: session_id,
|
|
121
|
+
persist: persist
|
|
118
122
|
)
|
|
119
123
|
end
|
|
120
124
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# Switches to a fallback model+provider when the primary LLM call fails
|
|
7
|
+
# with a transient error (rate limit, server error, service unavailable).
|
|
8
|
+
#
|
|
9
|
+
# Each fallback specifies both a model and a provider slug, so the
|
|
10
|
+
# middleware can switch from e.g. OpenAI to Anthropic transparently.
|
|
11
|
+
# Credentials for each provider are resolved automatically via
|
|
12
|
+
# {Ask::Auth.resolve}.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage — fallback to Anthropic when OpenAI is overloaded
|
|
15
|
+
# pipeline.use :model_fallback, fallbacks: [
|
|
16
|
+
# { model: "claude-sonnet-4", provider: :anthropic },
|
|
17
|
+
# { model: "gemini-2.0-flash", provider: :google }
|
|
18
|
+
# ]
|
|
19
|
+
#
|
|
20
|
+
# @example With failure-trigger customization
|
|
21
|
+
# pipeline.use :model_fallback, fallbacks: [
|
|
22
|
+
# { model: "claude-sonnet-4", provider: :anthropic, on_error: [Ask::RateLimitError, Ask::ServerError] }
|
|
23
|
+
# ]
|
|
24
|
+
#
|
|
25
|
+
# @example Using the block form to choose fallbacks dynamically
|
|
26
|
+
# pipeline.use :model_fallback, fallbacks: ->(error, request) {
|
|
27
|
+
# if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
|
|
28
|
+
# [{ model: "claude-sonnet-4", provider: :anthropic }] # use long-context model
|
|
29
|
+
# else
|
|
30
|
+
# [{ model: "gpt-4o-mini", provider: :openai }] # use cheaper model
|
|
31
|
+
# end
|
|
32
|
+
# }
|
|
33
|
+
class ModelFallback < Base
|
|
34
|
+
DEFAULT_ELIGIBLE_ERRORS = [
|
|
35
|
+
Ask::RateLimitError, Ask::ServerError, Ask::ServiceUnavailable
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def initialize(fallbacks:, eligible_errors: nil)
|
|
39
|
+
@fallbacks = fallbacks.respond_to?(:call) ? fallbacks : Array(fallbacks)
|
|
40
|
+
@eligible_errors = Array(eligible_errors || DEFAULT_ELIGIBLE_ERRORS)
|
|
41
|
+
raise ArgumentError, "At least one fallback is required" if Array(@fallbacks).empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def around_request(provider, request)
|
|
45
|
+
# Try primary provider
|
|
46
|
+
begin
|
|
47
|
+
return yield
|
|
48
|
+
rescue *@eligible_errors => e
|
|
49
|
+
result = try_fallbacks(request, error: e)
|
|
50
|
+
return result if result
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def try_fallbacks(request, error:)
|
|
58
|
+
fallback_list = resolve_fallback_list(error, request)
|
|
59
|
+
|
|
60
|
+
fallback_list.each do |fb|
|
|
61
|
+
begin
|
|
62
|
+
new_provider = build_fallback_provider(fb[:provider])
|
|
63
|
+
request[:model] = fb[:model]
|
|
64
|
+
return invoke_fallback(new_provider, request)
|
|
65
|
+
rescue *@eligible_errors
|
|
66
|
+
next # Try next fallback
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
nil # All fallbacks exhausted
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_fallback_list(error, request)
|
|
74
|
+
list = if @fallbacks.respond_to?(:call)
|
|
75
|
+
@fallbacks.call(error, request)
|
|
76
|
+
else
|
|
77
|
+
@fallbacks
|
|
78
|
+
end
|
|
79
|
+
raise "Fallback list must be an array of hashes, got #{list.class}" unless list.is_a?(Array)
|
|
80
|
+
list
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_fallback_provider(provider_slug)
|
|
84
|
+
slug = provider_slug.to_s
|
|
85
|
+
klass = Ask::Provider.resolve(slug)
|
|
86
|
+
klass.new(fallback_config(slug))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def invoke_fallback(provider, request)
|
|
90
|
+
provider.chat(
|
|
91
|
+
request[:messages],
|
|
92
|
+
model: request[:model],
|
|
93
|
+
tools: request[:tools],
|
|
94
|
+
temperature: request[:temperature],
|
|
95
|
+
stream: request[:stream],
|
|
96
|
+
schema: request[:schema],
|
|
97
|
+
**request[:extra_params]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fallback_config(slug)
|
|
102
|
+
key = Ask::Auth.resolve(:"#{slug}_api_key") rescue nil
|
|
103
|
+
config = { api_key: key }
|
|
104
|
+
config[:"#{slug}_api_key"] = key
|
|
105
|
+
Ask::LLM::Config.new(config)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -19,7 +19,8 @@ module Ask
|
|
|
19
19
|
KNOWN_MIDDLEWARES = {
|
|
20
20
|
retry_on_failure: "Ask::Agent::Middleware::RetryOnFailure",
|
|
21
21
|
log_calls: "Ask::Agent::Middleware::LogCalls",
|
|
22
|
-
default_settings: "Ask::Agent::Middleware::DefaultSettings"
|
|
22
|
+
default_settings: "Ask::Agent::Middleware::DefaultSettings",
|
|
23
|
+
model_fallback: "Ask::Agent::Middleware::ModelFallback"
|
|
23
24
|
}.freeze
|
|
24
25
|
|
|
25
26
|
def initialize
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Ask
|
|
|
18
18
|
attr_reader :skills_registry
|
|
19
19
|
|
|
20
20
|
def initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3,
|
|
21
|
-
compactor: nil, hooks: {}, persistence: nil,
|
|
21
|
+
compactor: nil, hooks: {}, state: nil, persistence: nil,
|
|
22
22
|
id: nil, system_prompt: nil, parallel_tools: true,
|
|
23
23
|
reflector: nil, telemetry: true, meta_agent: nil,
|
|
24
24
|
agent_dir: nil, **chat_options)
|
|
@@ -48,11 +48,10 @@ module Ask
|
|
|
48
48
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
49
49
|
@hooks = Hooks.new(hooks)
|
|
50
50
|
|
|
51
|
-
# Build system context from typed sources
|
|
52
51
|
@system_context = build_system_context(system_prompt)
|
|
53
52
|
apply_system_context
|
|
54
53
|
|
|
55
|
-
@
|
|
54
|
+
@state = state || persistence
|
|
56
55
|
|
|
57
56
|
reflector_opts = reflector.is_a?(Hash) ? reflector : {}
|
|
58
57
|
@reflector = if reflector
|
|
@@ -79,8 +78,7 @@ module Ask
|
|
|
79
78
|
|
|
80
79
|
emit(Events::SessionStart.new)
|
|
81
80
|
|
|
82
|
-
active_tools =
|
|
83
|
-
active_tools = @tools if active_tools.empty?
|
|
81
|
+
active_tools = @tools
|
|
84
82
|
|
|
85
83
|
if active_tools.empty? && !@_no_tools_instructed
|
|
86
84
|
@chat.add_message(role: :system, content: "You have no tools available. Do not claim you can look up information or use tools of any kind. Just respond based on your existing knowledge.")
|
|
@@ -90,16 +88,17 @@ module Ask
|
|
|
90
88
|
begin
|
|
91
89
|
@tool_executor.telemetry = @telemetry
|
|
92
90
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
91
|
+
response = @loop.run_turn(
|
|
92
|
+
chat: @chat,
|
|
93
|
+
message: message,
|
|
94
|
+
tools: active_tools,
|
|
95
|
+
tool_executor: @tool_executor,
|
|
96
|
+
compactor: @compactor,
|
|
97
|
+
hooks: @hooks,
|
|
98
|
+
event_emitter: self,
|
|
99
|
+
session_id: @id,
|
|
100
|
+
persist: @state ? method(:persist!) : nil
|
|
101
|
+
)
|
|
103
102
|
|
|
104
103
|
@total_input_tokens += @loop.last_input_tokens.to_i
|
|
105
104
|
@total_output_tokens += @loop.last_output_tokens.to_i
|
|
@@ -123,7 +122,7 @@ module Ask
|
|
|
123
122
|
raise
|
|
124
123
|
ensure
|
|
125
124
|
@running = false
|
|
126
|
-
persist! if @
|
|
125
|
+
persist! if @state
|
|
127
126
|
end
|
|
128
127
|
|
|
129
128
|
@tool_calls_made = @tool_executor.total_executions
|
|
@@ -194,34 +193,37 @@ module Ask
|
|
|
194
193
|
def deleted? = @deleted
|
|
195
194
|
|
|
196
195
|
def save
|
|
197
|
-
persist! if @
|
|
196
|
+
persist! if @state
|
|
198
197
|
end
|
|
199
198
|
|
|
200
199
|
def self.load(id, adapter:)
|
|
201
|
-
data = adapter.
|
|
200
|
+
data = adapter.get(id)
|
|
202
201
|
return nil unless data
|
|
203
202
|
|
|
203
|
+
data = deep_symbolize_keys(data)
|
|
204
|
+
|
|
204
205
|
session = new(
|
|
205
206
|
id: data[:id],
|
|
206
207
|
model: data.dig(:metadata, :model),
|
|
207
208
|
tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
|
|
208
|
-
|
|
209
|
+
state: adapter
|
|
209
210
|
)
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
212
|
+
data[:messages].each do |msg|
|
|
213
|
+
session.chat.add_message(
|
|
214
|
+
role: msg[:role].to_sym,
|
|
215
|
+
content: msg[:content],
|
|
216
|
+
tool_call_id: msg[:tool_call_id]
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
session.instance_variable_set(:@messages, session.chat.messages.dup)
|
|
221
|
+
session
|
|
220
222
|
end
|
|
221
223
|
|
|
222
224
|
def delete
|
|
223
225
|
@deleted = true
|
|
224
|
-
@
|
|
226
|
+
@state&.delete(@id)
|
|
225
227
|
end
|
|
226
228
|
|
|
227
229
|
def abort
|
|
@@ -291,7 +293,7 @@ module Ask
|
|
|
291
293
|
end
|
|
292
294
|
|
|
293
295
|
def persist!
|
|
294
|
-
@
|
|
296
|
+
@state.set(@id, {
|
|
295
297
|
id: @id,
|
|
296
298
|
messages: @chat.messages.map { |m|
|
|
297
299
|
{
|
|
@@ -359,6 +361,19 @@ module Ask
|
|
|
359
361
|
SystemContext.new(sources)
|
|
360
362
|
end
|
|
361
363
|
|
|
364
|
+
# Recursively convert string keys to symbol keys in hashes.
|
|
365
|
+
# Needed when loading session data that was serialized through JSON.
|
|
366
|
+
def self.deep_symbolize_keys(obj)
|
|
367
|
+
case obj
|
|
368
|
+
when Hash
|
|
369
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) }
|
|
370
|
+
when Array
|
|
371
|
+
obj.map { |e| deep_symbolize_keys(e) }
|
|
372
|
+
else
|
|
373
|
+
obj
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
362
377
|
# Render the system context and apply it to the chat.
|
|
363
378
|
def apply_system_context
|
|
364
379
|
rendered = @system_context.render
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Ask
|
|
|
21
21
|
class UnknownAgent < Error; end
|
|
22
22
|
|
|
23
23
|
module Extensions
|
|
24
|
+
autoload :Permissions, "ask/agent/extensions/permissions"
|
|
24
25
|
autoload :PermissionGate, "ask/agent/extensions/permission_gate"
|
|
25
26
|
autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
|
|
26
27
|
autoload :AuditLog, "ask/agent/extensions/audit_log"
|
|
@@ -32,6 +33,7 @@ module Ask
|
|
|
32
33
|
autoload :RetryOnFailure, "ask/agent/middleware/retry_on_failure"
|
|
33
34
|
autoload :LogCalls, "ask/agent/middleware/log_calls"
|
|
34
35
|
autoload :DefaultSettings, "ask/agent/middleware/default_settings"
|
|
36
|
+
autoload :ModelFallback, "ask/agent/middleware/model_fallback"
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
module StreamTransforms
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -160,6 +160,7 @@ files:
|
|
|
160
160
|
- lib/ask/agent/events.rb
|
|
161
161
|
- lib/ask/agent/extensions/audit_log.rb
|
|
162
162
|
- lib/ask/agent/extensions/permission_gate.rb
|
|
163
|
+
- lib/ask/agent/extensions/permissions.rb
|
|
163
164
|
- lib/ask/agent/extensions/rate_limiter.rb
|
|
164
165
|
- lib/ask/agent/hooks.rb
|
|
165
166
|
- lib/ask/agent/loop.rb
|
|
@@ -167,6 +168,7 @@ files:
|
|
|
167
168
|
- lib/ask/agent/middleware/base.rb
|
|
168
169
|
- lib/ask/agent/middleware/default_settings.rb
|
|
169
170
|
- lib/ask/agent/middleware/log_calls.rb
|
|
171
|
+
- lib/ask/agent/middleware/model_fallback.rb
|
|
170
172
|
- lib/ask/agent/middleware/pipeline.rb
|
|
171
173
|
- lib/ask/agent/middleware/retry_on_failure.rb
|
|
172
174
|
- lib/ask/agent/persistence/base.rb
|