activeagent 1.0.3 → 1.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/README.md +39 -3
  4. data/lib/active_agent/base.rb +4 -0
  5. data/lib/active_agent/model_capabilities.rb +89 -0
  6. data/lib/active_agent/providers/_base_provider.rb +23 -2
  7. data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
  8. data/lib/active_agent/providers/errors.rb +140 -0
  9. data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
  10. data/lib/active_agent/railtie.rb +5 -0
  11. data/lib/active_agent/telemetry/configuration.rb +115 -171
  12. data/lib/active_agent/telemetry/instrumentation.rb +141 -14
  13. data/lib/active_agent/telemetry/reporter.rb +12 -165
  14. data/lib/active_agent/telemetry/span.rb +18 -245
  15. data/lib/active_agent/telemetry/tool_origin.rb +90 -0
  16. data/lib/active_agent/telemetry/tracer.rb +67 -70
  17. data/lib/active_agent/telemetry.rb +2 -2
  18. data/lib/active_agent/version.rb +1 -1
  19. data/lib/active_agent.rb +1 -1
  20. metadata +38 -42
  21. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +0 -117
  22. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +0 -54
  23. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +0 -126
  24. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +0 -103
  25. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +0 -56
  26. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +0 -14
  27. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +0 -49
  28. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +0 -65
  29. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +0 -77
  30. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +0 -256
  31. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +0 -113
  32. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +0 -208
  33. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +0 -60
  34. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +0 -46
  35. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +0 -125
  36. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +0 -83
  37. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +0 -52
  38. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +0 -169
  39. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +0 -193
  40. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +0 -198
  41. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +0 -105
  42. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +0 -135
  43. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +0 -143
  44. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +0 -36
  45. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +0 -94
  46. data/lib/active_agent/dashboard/config/routes.rb +0 -78
  47. data/lib/active_agent/dashboard/engine.rb +0 -39
  48. data/lib/active_agent/dashboard.rb +0 -151
  49. data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
  50. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
  51. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
  52. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
  53. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
  54. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
  55. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
  56. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
  57. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
  58. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
  59. data/lib/generators/active_agent/dashboard/install_generator.rb +0 -64
  60. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +0 -30
  61. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90c3dbcc6a09014256fc36ac0bf88de83aa6a85d95c688be1eb289dde397c633
4
- data.tar.gz: 6bdf588db4ab2809efaddde5ee7b06fd58ad59e1b5b3479e6c653828d90a4ad9
3
+ metadata.gz: 867cf01603bb86b99c7abdbe6cc9e731264dc60bfdf3e89ca19a4d25fca5ea3d
4
+ data.tar.gz: c8b86f8988ff27931f4a82f8aa71aa04168bceae00f8f9cab6b98507dd3d2b94
5
5
  SHA512:
6
- metadata.gz: a70e79c1a1b0f683ac3322476b9f2cdaa70deaf3f8e035d2d5307e8fb6561ea5cc5b2589edfa1b2e3a68e28a2db0806d591d1f6726cbd6ab39ed512a072388db
7
- data.tar.gz: a94dd4a891041044b02117ddc2bcf448cbf1c31c764fbaf8e3cd88b0b81cddf636bb90cac2ddfcb4706ec91b46da2490fdb24fd1e967d9aa200e95ca655a8caf
6
+ metadata.gz: 8c81f0537fddda60bbe3049cdb36b0478130d969ba0d35af408f3a5c527e2448d8e733638cd94b6c72c8f4b8d1ec13da32877baae3101e0373cc7d173253d06c
7
+ data.tar.gz: 6af0f6a1879a9e291581a51beb5b6b96ac2ec641a9ebc007cb2345c695e1f746741fbb667bd761f889591d15c7ffeb917e7ecb7c5d1151f4d6687af3f87a3930
data/CHANGELOG.md CHANGED
@@ -5,6 +5,138 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - Unreleased
9
+
10
+ ### ⚠️ The dashboard has moved to its own gem
11
+
12
+ The dashboard engine that shipped inside `activeagent` is now a separate
13
+ gem, **`actionagent`**. Nothing is gone — the dashboard is the same
14
+ dashboard, and it gained a great deal in this release — but it comes from a
15
+ different gem now. `activeagent` is the framework alone: it no longer
16
+ defines `ActiveAgent::Dashboard`, and no longer pulls Active Record into
17
+ apps that do not use it.
18
+
19
+ **If you mount the dashboard, add the new gem in the same change that
20
+ upgrades `activeagent`:**
21
+
22
+ ```ruby
23
+ gem "activeagent", "~> 1.2"
24
+ gem "actionagent", "~> 1.2" # required if you mount the dashboard
25
+ ```
26
+
27
+ This is a minor version, so a `~> 1.0` or `~> 1.1` constraint **will** pick
28
+ it up on the next `bundle update`. If you mount the dashboard and do not add
29
+ `actionagent` at the same time, the app fails at boot with
30
+ `NameError: uninitialized constant ActiveAgent::Dashboard`, raised by your
31
+ own initializer or by the `mount ActiveAgent::Dashboard::Engine` line in
32
+ `config/routes.rb`. Adding the gem is the whole fix — your existing
33
+ configuration keeps working through the compatibility shims below.
34
+
35
+ If you do not mount the dashboard, there is nothing to do: the framework API
36
+ is unchanged, and the gem is 95% smaller.
37
+
38
+ With `actionagent` installed, the old constants keep resolving through
39
+ `ActionAgent::Compatibility` with a deprecation warning:
40
+
41
+ - `ActiveAgent::Dashboard` → `ActionAgent`
42
+ - `ActiveAgent::TelemetryTrace` → `ActionAgent::TelemetryTrace`
43
+ - `ActiveAgent::ProcessTelemetryTracesJob` → `ActionAgent::ProcessTelemetryTracesJob`
44
+
45
+ That last one matters beyond tidiness: Active Job serializes the class name
46
+ into the queue payload, so jobs enqueued before the upgrade still resolve
47
+ after it.
48
+
49
+ Other changes for mounted installs:
50
+
51
+ - **The server-rendered traces console moves from `/traces` to
52
+ `/console/traces`.** `/traces` is now the React traces view — the same
53
+ data, with more of it.
54
+ - **The mount is authenticated everywhere but development and test.** The
55
+ sandbox API, the session-recording capture endpoints and the template
56
+ endpoints previously allowed anonymous access; they no longer do. The
57
+ `GET /api/session_recordings/demo` endpoint is removed.
58
+ - **`current_user_method` / `current_account_method` are superseded by
59
+ `current_user_resolver` / `current_account_resolver`.** The engine's
60
+ controllers are their own base class, so a host app's `current_user`
61
+ helper is not available to them.
62
+ - **An unresolved owner now scopes to nothing rather than to everything.**
63
+ If you configure `user_class` or `account_class`, make sure the matching
64
+ resolver actually returns a record, or the dashboard will show no data.
65
+ - Existing installs upgrading from the in-gem dashboard: re-run
66
+ `rails generate action_agent:install`. It detects the migrations you
67
+ already have and emits only what is missing.
68
+
69
+ ## [1.1.0] - 2026-08-12
70
+
71
+ ### Dashboard — self-hosted (enterprise) mount readiness
72
+
73
+ The engine can now be mounted in any Rails app as the self-hosted
74
+ observability surface (see `docs/framework/self-hosted-observability.md`):
75
+
76
+ - **One install generator**: the duplicate `active_agent:dashboard:install`
77
+ variant that copied eight migrations (agents, sandboxes, recordings —
78
+ tables for models with no shipped controllers or routes) is removed.
79
+ The surviving generator installs the telemetry traces table only and
80
+ gains `--skip_migrations` / `--skip_routes`; its initializer template now
81
+ covers authentication, `ingest_api_key`, and multi-tenant options.
82
+ - **Canonical mount path is `/activeagents`** (generator, dummy app and
83
+ docs updated). `Telemetry::Configuration#resolved_endpoint` now reports
84
+ the ingest path for wherever the engine is actually mounted — any mount
85
+ path, including `/` on a dedicated subdomain — instead of a hardcoded
86
+ constant, falling back to `LOCAL_ENDPOINT_PATH` when it isn't mounted.
87
+ Note this is informational: `local_storage` capture writes through the
88
+ trace model without HTTP, and remote apps set `endpoint:` explicitly.
89
+ - **`TracesController` honors configuration**: index/metrics/time-series
90
+ queries now go through `ActiveAgent::Dashboard.trace_model` (previously
91
+ only `show` did) and are scoped with `for_account(current_owner)`, so a
92
+ `trace_model_class` override and multi-tenant scoping apply everywhere.
93
+ - **Single-tenant ingest auth**: new `config.ingest_api_key` requires a
94
+ matching Bearer token on `POST <mount>/api/traces` when set. The
95
+ telemetry reporter and ruby_llm_telemetry already send their `api_key`
96
+ as a Bearer header, so remote apps need no changes.
97
+ - **Metrics page no longer 500s with data**: the per-agent stats table
98
+ read a grouped SQL alias through a model method that expected per-trace
99
+ token columns.
100
+ - **Mount detection is route-set based**: the ingest path is resolved by
101
+ locating the mounted engine in the host's routes rather than assuming
102
+ the default `active_agent_path` helper, so `mount ... => "/", as:
103
+ :something_else` and constraint-wrapped (subdomain) mounts resolve
104
+ correctly instead of silently falling back.
105
+ - Deprecated the never-consumed `base_controller_class` config attribute:
106
+ it remains a no-op accessor with its historical default so existing
107
+ initializers keep booting, and will be removed in the next major.
108
+
109
+ ### Dashboard & Telemetry — dev console readiness
110
+
111
+ The dashboard engine — Active Agent's local dev console — now works out of
112
+ the box (production observability is the hosted platform product):
113
+
114
+ - **Engine load paths fixed**: `Engine.find_root` now points at the
115
+ dashboard directory, so `ActiveAgent::TelemetryTrace`,
116
+ `ProcessTelemetryTracesJob`, the API controller, views and engine routes
117
+ are auto-discovered in host apps (previously they required manual
118
+ `require`s). The engine is also required eagerly with Rails, since
119
+ engines defined lazily miss initializer collection.
120
+ - **Routes now match shipped controllers**: the engine exposes traces,
121
+ metrics and the ingest API (`<mount>/api/traces`); routes to
122
+ never-shipped controllers (agents, sandboxes, templates, recordings,
123
+ api/v1) were removed. Engine root renders the traces index.
124
+ - **`local_storage` telemetry mode fixed**: tracer payloads are
125
+ symbol-keyed and were silently dropped by the string-keyed ingestion
126
+ normalizer; the reporter now stringifies and honors
127
+ `ActiveAgent::Dashboard.trace_model` overrides.
128
+ - **Token totals no longer double-count**: instrumentation mirrors LLM
129
+ token usage onto the root span; `TelemetryTrace.create_from_payload`
130
+ now counts child spans as the source of truth.
131
+ - **Span waterfall renders real offsets** (was pinned to 0ms), turbo-rails
132
+ is now optional (previously 500s without it), layout route helpers fixed,
133
+ `Agent.for_owner` scope added, synchronous ingest capped at 100
134
+ traces/request.
135
+ - **New docs** (`docs/framework/dashboard.md`, README section) covering
136
+ install, authentication (none by default — see docs), remote ingestion
137
+ and multi-tenant mode; dashboard engine test suite added
138
+ (`test/dashboard/`).
139
+
8
140
  ## [1.0.0] - 2025-11-21
9
141
 
10
142
  Major refactor with breaking changes. Complete provider rewrite. New modular architecture.
data/README.md CHANGED
@@ -25,6 +25,10 @@ Use bundler to add activeagent to your Gemfile and install:
25
25
  bundle add activeagent
26
26
  ```
27
27
 
28
+ That is the framework — agents, providers, generation and telemetry
29
+ reporting. The dashboard is a second gem, `actionagent`, added separately
30
+ when you want it; see [Dashboard & Dev Console](#dashboard--dev-console).
31
+
28
32
  Add the generation provider gem you want to use:
29
33
 
30
34
  ```bash
@@ -127,6 +131,38 @@ development:
127
131
  service: "RubyLLM"
128
132
  ```
129
133
 
134
+ ## Dashboard & Dev Console
135
+
136
+ The dashboard is its own gem, `actionagent`: a mountable Rails engine with
137
+ traces and span waterfalls, token usage and per-agent metrics, plus the agent
138
+ builder, runs, conversations, evaluations, scorecards and cost estimates — so
139
+ you can watch and drive your agents while you build. It ships separately
140
+ because its models are Active Record models and it runs agents through
141
+ [solid_agent](https://github.com/activeagents/solid_agent) — neither of which
142
+ `activeagent` depends on, so an app that only runs agents installs neither.
143
+
144
+ ```bash
145
+ bundle add actionagent
146
+ rails generate action_agent:install
147
+ rails db:migrate
148
+ ```
149
+
150
+ ```yaml
151
+ # config/active_agent.yml
152
+ telemetry:
153
+ enabled: true
154
+ local_storage: true
155
+ ```
156
+
157
+ The generator mounts the engine at `/activeagents` — open it and every
158
+ generation appears as a trace. See
159
+ [docs/framework/dashboard.md](docs/framework/dashboard.md) for
160
+ authentication, remote ingestion, and multi-tenant mode. The hosted
161
+ platform at [activeagents.ai](https://activeagents.ai) runs this same
162
+ engine multi-tenant, adding what a hosted product has to have — accounts,
163
+ plans, billing, quotas and managed sandboxes; every workspace starts with
164
+ a free low-volume trial.
165
+
130
166
  ## Features
131
167
 
132
168
  - **Agent-Oriented Programming**: Build AI applications using familiar Rails patterns
@@ -173,12 +209,12 @@ response = prompt.generate_now
173
209
 
174
210
  - [Documentation](https://docs.activeagents.ai)
175
211
  - [Getting Started Guide](https://docs.activeagents.ai/getting_started)
176
- - [API Reference](https://docs.activeagents.ai/docs/framework)
177
- - [Examples](https://docs.activeagents.ai/docs/agents)
212
+ - [API Reference](https://docs.activeagents.ai/framework)
213
+ - [Examples](https://docs.activeagents.ai/agents)
178
214
 
179
215
  ## Contributing
180
216
 
181
- We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
217
+ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.MD) for details.
182
218
 
183
219
  ## License
184
220
 
@@ -304,6 +304,10 @@ module ActiveAgent
304
304
  # Render out proc/lamda attributes before rendering templates
305
305
  parameters.deep_transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
306
306
 
307
+ # Strip parameters the target model rejects (e.g. temperature/top_p
308
+ # on thinking-first models) before they reach the provider.
309
+ ModelCapabilities.sanitize!(parameters)
310
+
307
311
  # Apply Callbacks
308
312
  parameters.merge!(
309
313
  trace_id: prompt_options[:trace_id] || SecureRandom.uuid,
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ # Per-model capability quirks, applied before a request reaches the
5
+ # provider. Vendors ship models that reject otherwise-standard sampling
6
+ # parameters (thinking-first models steered by prompting/effort instead)
7
+ # with an API 400 — this registry strips those parameters up front so an
8
+ # agent configured with a shared temperature keeps working across model
9
+ # switches.
10
+ #
11
+ # The built-in rules cover the known families; apps can extend the
12
+ # registry for new or self-hosted models:
13
+ #
14
+ # @example Register a custom rule
15
+ # ActiveAgent::ModelCapabilities.register(/\Amy-reasoning-model/, unsupported: [:temperature, :top_p])
16
+ #
17
+ # @example Disable sanitization entirely
18
+ # ActiveAgent::ModelCapabilities.enabled = false
19
+ module ModelCapabilities
20
+ SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
21
+
22
+ # Model families that reject sampling parameters with a 400:
23
+ # - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
24
+ # Fable 5 / Mythos 5)
25
+ # - OpenAI reasoning models (o-series, GPT-5 family)
26
+ BUILTIN_RULES = [
27
+ { pattern: /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/, unsupported: SAMPLING_PARAMS },
28
+ { pattern: /\A(o1|o3|o4)(-|$)/, unsupported: SAMPLING_PARAMS },
29
+ { pattern: /\Agpt-5/, unsupported: SAMPLING_PARAMS }
30
+ ].freeze
31
+
32
+ class << self
33
+ # Master switch; on by default. Set false to send parameters through
34
+ # untouched (the vendor then enforces its own rules).
35
+ attr_writer :enabled
36
+
37
+ def enabled
38
+ return @enabled unless @enabled.nil?
39
+
40
+ true
41
+ end
42
+
43
+ # Registers an app-defined capability rule ahead of the built-ins.
44
+ #
45
+ # @param pattern [Regexp] matched against the model name
46
+ # @param unsupported [Array<Symbol>] parameter keys the model rejects
47
+ def register(pattern, unsupported:)
48
+ custom_rules << { pattern: pattern, unsupported: unsupported.map(&:to_sym) }
49
+ end
50
+
51
+ def custom_rules
52
+ @custom_rules ||= []
53
+ end
54
+
55
+ def reset!
56
+ @custom_rules = []
57
+ @enabled = nil
58
+ end
59
+
60
+ # @return [Array<Symbol>] parameter keys the model rejects
61
+ def unsupported_params(model)
62
+ return [] if model.nil?
63
+
64
+ (custom_rules + BUILTIN_RULES).each do |rule|
65
+ return rule[:unsupported] if model.to_s.match?(rule[:pattern])
66
+ end
67
+ []
68
+ end
69
+
70
+ def sampling_supported?(model)
71
+ (unsupported_params(model) & SAMPLING_PARAMS).empty?
72
+ end
73
+
74
+ # Strips parameters the model rejects, in place. Returns the removed
75
+ # keys (empty when nothing applied).
76
+ #
77
+ # @param parameters [Hash] prepared prompt parameters (must carry :model)
78
+ # @return [Array<Symbol>] removed parameter keys
79
+ def sanitize!(parameters)
80
+ return [] unless enabled
81
+ return [] unless parameters.is_a?(Hash)
82
+
83
+ removed = unsupported_params(parameters[:model]).select { |key| parameters.key?(key) }
84
+ removed.each { |key| parameters.delete(key) }
85
+ removed
86
+ end
87
+ end
88
+ end
89
+ end
@@ -55,7 +55,13 @@ module ActiveAgent
55
55
  :request, :message_stack, # Runtime
56
56
  :stream_broadcaster, :streaming, # Callback (Streams)
57
57
  :tools_function, # Callback (Tools)
58
- :usage_stack # Usage Tracking
58
+ :usage_stack, # Usage Tracking
59
+ :max_tool_turns, :tool_turns # Tool-loop safety
60
+
61
+ # Upper bound on tool-calling round-trips within one generation. A
62
+ # model that keeps emitting tool calls otherwise recurses until the
63
+ # provider stops it — override per agent/prompt with max_tool_turns:.
64
+ DEFAULT_MAX_TOOL_TURNS = 25
59
65
 
60
66
  # @return [String] e.g., "Anthropic", "OpenAI"
61
67
  def self.service_name
@@ -106,6 +112,8 @@ module ActiveAgent
106
112
  self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
107
113
  self.streaming = false
108
114
  self.tools_function = kwargs.delete(:tools_function)
115
+ self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
116
+ self.tool_turns = 0
109
117
  self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
110
118
  self.context = kwargs
111
119
  self.message_stack = []
@@ -344,7 +352,7 @@ module ActiveAgent
344
352
  message_stack.push(*api_messages)
345
353
  end
346
354
 
347
- if (tool_calls = process_prompt_finished_extract_function_calls)&.any?
355
+ if (tool_calls = process_prompt_finished_extract_function_calls)&.any? && tool_turn_allowed?
348
356
  process_function_calls(tool_calls)
349
357
  resolve_prompt
350
358
  else
@@ -373,6 +381,19 @@ module ActiveAgent
373
381
  end
374
382
  end
375
383
 
384
+ # Counts a tool round-trip against the per-generation cap. When the
385
+ # cap is hit the loop finishes cleanly with the messages gathered so
386
+ # far (a partial result) instead of recursing indefinitely.
387
+ #
388
+ # @return [Boolean] whether another tool round-trip may run
389
+ def tool_turn_allowed?
390
+ self.tool_turns += 1
391
+ return true if max_tool_turns.nil? || tool_turns <= max_tool_turns
392
+
393
+ instrument("tool_turns_exceeded.active_agent", limit: max_tool_turns)
394
+ false
395
+ end
396
+
376
397
  # @abstract
377
398
  # @param api_response [Object]
378
399
  # @return [Array<Message>, nil]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+
3
5
  module ActiveAgent
4
6
  module Providers
5
7
  # Provides exception handling for provider operations.
@@ -54,7 +56,16 @@ module ActiveAgent
54
56
  def with_exception_handling(&block)
55
57
  yield
56
58
  rescue => exception
57
- rescue_with_handler(exception) || raise
59
+ # Vendor API failures are normalized into the framework taxonomy
60
+ # (Errors::RateLimited, Errors::ContextLengthExceeded, ...) so
61
+ # rescue_from policy is portable across providers; the original
62
+ # exception is preserved as #cause. Anything unrecognizable —
63
+ # including ordinary Ruby errors — passes through untouched.
64
+ exception = Errors::Taxonomy.normalize(
65
+ exception,
66
+ provider_tag: (tag_name if respond_to?(:tag_name))
67
+ )
68
+ rescue_with_handler(exception) || raise(exception)
58
69
  nil # Discard handler return value to prevent polluting raw_response
59
70
  end
60
71
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Providers
5
+ # Typed provider failures, normalized across vendor SDKs.
6
+ #
7
+ # Every vendor raises its own exception classes for the same underlying
8
+ # conditions (rate limits, context overflows, content filters, outages),
9
+ # which makes retry/backoff/fallback policy impossible to express
10
+ # portably. The taxonomy classifies vendor errors into a small set of
11
+ # framework types — the original exception is preserved as +#cause+, so
12
+ # nothing is lost.
13
+ #
14
+ # @example Portable retry policy
15
+ # rescue_from ActiveAgent::Providers::Errors::RateLimited do |error|
16
+ # retry_job wait: 30.seconds
17
+ # end
18
+ #
19
+ # @example Fallback on outage
20
+ # rescue_from ActiveAgent::Providers::Errors::ServiceUnavailable do |error|
21
+ # FallbackAgent.with(params).ask.generate_later
22
+ # end
23
+ module Errors
24
+ # Base class for normalized provider failures.
25
+ class ProviderError < StandardError
26
+ # @return [Integer, nil] HTTP status from the vendor error, when known
27
+ attr_reader :status
28
+
29
+ # @return [String, nil] provider tag (e.g. "Anthropic", "OpenAI::Chat")
30
+ attr_reader :provider_tag
31
+
32
+ def initialize(message = nil, status: nil, provider_tag: nil)
33
+ super(message)
34
+ @status = status
35
+ @provider_tag = provider_tag
36
+ end
37
+ end
38
+
39
+ # 429s / vendor rate & quota limits. Retryable with backoff.
40
+ class RateLimited < ProviderError; end
41
+
42
+ # The prompt exceeded the model's context window. Not retryable
43
+ # without shrinking the input.
44
+ class ContextLengthExceeded < ProviderError; end
45
+
46
+ # Invalid, expired, or unauthorized credentials (401/403).
47
+ class AuthenticationFailed < ProviderError; end
48
+
49
+ # The vendor's safety layer refused the request or response.
50
+ class ContentFiltered < ProviderError; end
51
+
52
+ # Vendor-side failure or overload (5xx, timeouts, connection drops).
53
+ # Retryable; a natural trigger for provider fallback.
54
+ class ServiceUnavailable < ProviderError; end
55
+
56
+ # Malformed or unsupported request the vendor rejected (400/422)
57
+ # that doesn't classify more specifically.
58
+ class InvalidRequest < ProviderError; end
59
+
60
+ # Classifies vendor SDK exceptions into the taxonomy. Unrecognizable
61
+ # exceptions (including ordinary Ruby errors) pass through untouched —
62
+ # only errors that look like vendor API failures are normalized.
63
+ module Taxonomy
64
+ # Vendor SDK class names (demodulized) → taxonomy class. Covers the
65
+ # official anthropic/openai gems and SDKs following their naming.
66
+ NAME_MAP = {
67
+ "RateLimitError" => RateLimited,
68
+ "AuthenticationError" => AuthenticationFailed,
69
+ "PermissionDeniedError" => AuthenticationFailed,
70
+ "ContentFilterError" => ContentFiltered,
71
+ "InternalServerError" => ServiceUnavailable,
72
+ "APIConnectionError" => ServiceUnavailable,
73
+ "APIConnectionTimeoutError" => ServiceUnavailable,
74
+ "APITimeoutError" => ServiceUnavailable,
75
+ "OverloadedError" => ServiceUnavailable,
76
+ "ServiceUnavailableError" => ServiceUnavailable,
77
+ "BadRequestError" => InvalidRequest,
78
+ "UnprocessableEntityError" => InvalidRequest
79
+ }.freeze
80
+
81
+ STATUS_MAP = {
82
+ 400 => InvalidRequest,
83
+ 401 => AuthenticationFailed,
84
+ 403 => AuthenticationFailed,
85
+ 408 => ServiceUnavailable,
86
+ 422 => InvalidRequest,
87
+ 429 => RateLimited,
88
+ 529 => ServiceUnavailable # Anthropic "overloaded"
89
+ }.freeze
90
+
91
+ CONTEXT_LENGTH_PATTERN = /context length|context_length|maximum context|context window|too many tokens|prompt is too long|input (?:is )?too long/i
92
+ CONTENT_FILTER_PATTERN = /content (?:filter|policy|management)|filtered due to|blocked by|safety (?:system|filter)/i
93
+
94
+ class << self
95
+ # @param exception [Exception]
96
+ # @param provider_tag [String, nil]
97
+ # @return [Exception] a taxonomy error, or the original exception
98
+ # when it doesn't classify
99
+ def normalize(exception, provider_tag: nil)
100
+ return exception if exception.is_a?(ProviderError)
101
+
102
+ klass = classify(exception)
103
+ return exception unless klass
104
+
105
+ klass.new(exception.message, status: status_of(exception), provider_tag: provider_tag)
106
+ end
107
+
108
+ # @return [Class, nil]
109
+ def classify(exception)
110
+ name = exception.class.name.to_s.demodulize
111
+ status = status_of(exception)
112
+ api_error = NAME_MAP.key?(name) || !status.nil?
113
+ return nil unless api_error
114
+
115
+ message = exception.message.to_s
116
+ return ContextLengthExceeded if CONTEXT_LENGTH_PATTERN.match?(message)
117
+ return ContentFiltered if CONTENT_FILTER_PATTERN.match?(message)
118
+
119
+ NAME_MAP[name] || STATUS_MAP[status] || (status && status >= 500 ? ServiceUnavailable : nil)
120
+ end
121
+
122
+ # @return [Integer, nil]
123
+ def status_of(exception)
124
+ [ :status, :status_code, :http_status, :code ].each do |reader|
125
+ next unless exception.respond_to?(reader)
126
+
127
+ value = begin
128
+ exception.public_send(reader)
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ return value if value.is_a?(Integer)
133
+ end
134
+ nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -55,12 +55,18 @@ module ActiveAgent
55
55
  OpenAI::Chat::Transforms.normalize_messages(messages)
56
56
  end
57
57
 
58
- # Normalizes instructions using OpenAI transforms
58
+ # Normalizes instructions using OpenAI transforms, then remaps the
59
+ # role: OpenAI's transforms emit the "developer" role, but the chat
60
+ # templates of Ollama-served models (qwen, llama, gemma, …) only
61
+ # know "system" — a "developer" message is silently dropped, so the
62
+ # model never sees its instructions.
59
63
  #
60
64
  # @param instructions [Array<String>, String]
61
- # @return [Array<OpenAI::Models::Chat::ChatCompletionMessageParam>]
65
+ # @return [Array<Hash>] system messages
62
66
  def normalize_instructions(instructions)
63
- OpenAI::Chat::Transforms.normalize_instructions(instructions)
67
+ OpenAI::Chat::Transforms.normalize_instructions(instructions).map do |message|
68
+ message.is_a?(Hash) ? message.merge(role: "system") : message
69
+ end
64
70
  end
65
71
 
66
72
  # Cleans up serialized request for API submission
@@ -67,6 +67,11 @@ module ActiveAgent
67
67
  include ActiveAgent::Telemetry::Instrumentation
68
68
  instrument_telemetry!
69
69
  end
70
+
71
+ # Flush remaining traces when the process exits — without this,
72
+ # short-lived processes (rails runner, jobs, deploys rolling a
73
+ # server) drop whatever was buffered since the last interval flush.
74
+ at_exit { ActiveAgent::Telemetry.shutdown }
70
75
  end
71
76
  # endregion telemetry_configuration
72
77