ask-agent 0.12.1 → 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 +44 -28
- 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
|
|
@@ -89,16 +88,17 @@ module Ask
|
|
|
89
88
|
begin
|
|
90
89
|
@tool_executor.telemetry = @telemetry
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
)
|
|
102
102
|
|
|
103
103
|
@total_input_tokens += @loop.last_input_tokens.to_i
|
|
104
104
|
@total_output_tokens += @loop.last_output_tokens.to_i
|
|
@@ -122,7 +122,7 @@ module Ask
|
|
|
122
122
|
raise
|
|
123
123
|
ensure
|
|
124
124
|
@running = false
|
|
125
|
-
persist! if @
|
|
125
|
+
persist! if @state
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
@tool_calls_made = @tool_executor.total_executions
|
|
@@ -193,34 +193,37 @@ module Ask
|
|
|
193
193
|
def deleted? = @deleted
|
|
194
194
|
|
|
195
195
|
def save
|
|
196
|
-
persist! if @
|
|
196
|
+
persist! if @state
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
def self.load(id, adapter:)
|
|
200
|
-
data = adapter.
|
|
200
|
+
data = adapter.get(id)
|
|
201
201
|
return nil unless data
|
|
202
202
|
|
|
203
|
+
data = deep_symbolize_keys(data)
|
|
204
|
+
|
|
203
205
|
session = new(
|
|
204
206
|
id: data[:id],
|
|
205
207
|
model: data.dig(:metadata, :model),
|
|
206
208
|
tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
|
|
207
|
-
|
|
209
|
+
state: adapter
|
|
208
210
|
)
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
219
222
|
end
|
|
220
223
|
|
|
221
224
|
def delete
|
|
222
225
|
@deleted = true
|
|
223
|
-
@
|
|
226
|
+
@state&.delete(@id)
|
|
224
227
|
end
|
|
225
228
|
|
|
226
229
|
def abort
|
|
@@ -290,7 +293,7 @@ module Ask
|
|
|
290
293
|
end
|
|
291
294
|
|
|
292
295
|
def persist!
|
|
293
|
-
@
|
|
296
|
+
@state.set(@id, {
|
|
294
297
|
id: @id,
|
|
295
298
|
messages: @chat.messages.map { |m|
|
|
296
299
|
{
|
|
@@ -358,6 +361,19 @@ module Ask
|
|
|
358
361
|
SystemContext.new(sources)
|
|
359
362
|
end
|
|
360
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
|
+
|
|
361
377
|
# Render the system context and apply it to the chat.
|
|
362
378
|
def apply_system_context
|
|
363
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
|