axn 0.1.0.pre.alpha.4.3 → 0.1.0.pre.alpha.5
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/.rubocop.yml +109 -0
- data/AGENTS-consuming.md +282 -0
- data/AGENTS-tool-adapters.md +250 -0
- data/CHANGELOG.md +173 -0
- data/README.md +20 -2
- data/lib/axn/async/adapters/active_job.rb +6 -0
- data/lib/axn/async/adapters/sidekiq/auto_configure.rb +21 -2
- data/lib/axn/async/adapters/sidekiq/death_handler.rb +13 -3
- data/lib/axn/async/adapters/sidekiq/middleware.rb +3 -2
- data/lib/axn/async/adapters/sidekiq/worker.rb +47 -0
- data/lib/axn/async/adapters/sidekiq.rb +125 -35
- data/lib/axn/async/adapters.rb +10 -2
- data/lib/axn/async/batch_enqueue.rb +32 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +112 -34
- data/lib/axn/async/exception_reporting.rb +97 -18
- data/lib/axn/async/ownership.rb +101 -0
- data/lib/axn/async.rb +37 -15
- data/lib/axn/configurable.rb +667 -0
- data/lib/axn/configuration.rb +249 -32
- data/lib/axn/core/ambient_context.rb +298 -0
- data/lib/axn/core/automatic_logging.rb +65 -7
- data/lib/axn/core/context/facade.rb +50 -42
- data/lib/axn/core/context/facade_inspector.rb +230 -69
- data/lib/axn/core/context/internal.rb +37 -17
- data/lib/axn/core/context.rb +72 -0
- data/lib/axn/core/contract/redaction.rb +550 -0
- data/lib/axn/core/contract/shape_declaration.rb +535 -0
- data/lib/axn/core/contract/subfield_contradictions.rb +228 -0
- data/lib/axn/core/contract.rb +1799 -125
- data/lib/axn/core/contract_for_subfields.rb +636 -58
- data/lib/axn/core/default_call.rb +2 -2
- data/lib/axn/core/executor.rb +1654 -0
- data/lib/axn/core/extension_metadata.rb +50 -0
- data/lib/axn/core/field_resolvers/extract.rb +117 -9
- data/lib/axn/core/field_resolvers/model.rb +35 -19
- data/lib/axn/core/field_resolvers.rb +32 -2
- data/lib/axn/core/flow/callbacks.rb +0 -1
- data/lib/axn/core/flow/fails_on.rb +86 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +27 -36
- data/lib/axn/core/flow/handlers/invoker.rb +20 -4
- data/lib/axn/core/flow/handlers/matcher.rb +33 -22
- data/lib/axn/core/flow/handlers/resolvers/message_resolver.rb +177 -27
- data/lib/axn/core/flow/messages.rb +21 -16
- data/lib/axn/core/flow.rb +2 -0
- data/lib/axn/core/logging.rb +6 -1
- data/lib/axn/core/method_shadowing.rb +38 -0
- data/lib/axn/core/naming.rb +56 -0
- data/lib/axn/core/nesting_tracking.rb +46 -12
- data/lib/axn/core/schema_reflection.rb +79 -0
- data/lib/axn/core/semantic_hints.rb +32 -0
- data/lib/axn/core/tagging.rb +166 -0
- data/lib/axn/core/tool_declaration.rb +239 -0
- data/lib/axn/core/validation/base.rb +383 -0
- data/lib/axn/core/validation/fields.rb +171 -40
- data/lib/axn/core/validation/validators/non_emptiness_validator.rb +70 -0
- data/lib/axn/core/validation/validators/shape_validator.rb +165 -6
- data/lib/axn/core/validation/validators/type_validator.rb +29 -3
- data/lib/axn/core/validation/validators/validate_validator.rb +35 -5
- data/lib/axn/core/versioning.rb +80 -0
- data/lib/axn/core.rb +47 -13
- data/lib/axn/error.rb +38 -0
- data/lib/axn/exceptions.rb +388 -15
- data/lib/axn/extensions/config.rb +23 -0
- data/lib/axn/extensions/serialization.rb +49 -0
- data/lib/axn/extensions.rb +237 -0
- data/lib/axn/extras/strategies/client.rb +28 -3
- data/lib/axn/extras/strategies/vernier.rb +1 -1
- data/lib/axn/factory.rb +159 -20
- data/lib/axn/field_declarations.rb +27 -0
- data/lib/axn/form_object.rb +4 -0
- data/lib/axn/internal/async_serialization.rb +198 -0
- data/lib/axn/internal/call_logger.rb +85 -29
- data/lib/axn/internal/carried_presentation.rb +27 -0
- data/lib/axn/internal/coercion.rb +174 -0
- data/lib/axn/internal/contract_error_handling.rb +20 -1
- data/lib/axn/internal/current_call_options.rb +53 -0
- data/lib/axn/internal/cycle_guard.rb +109 -0
- data/lib/axn/internal/exception_classification.rb +41 -0
- data/lib/axn/internal/exception_context.rb +55 -82
- data/lib/axn/internal/field_config.rb +100 -18
- data/lib/axn/internal/identity.rb +120 -0
- data/lib/axn/internal/native_methods.rb +387 -0
- data/lib/axn/internal/reflection/property_names.rb +998 -0
- data/lib/axn/internal/reflection/schema.rb +1795 -0
- data/lib/axn/internal/reflection/values.rb +634 -0
- data/lib/axn/internal/reflection.rb +43 -0
- data/lib/axn/internal/registry.rb +5 -4
- data/lib/axn/internal/rendering.rb +154 -0
- data/lib/axn/internal/resolved_subfields.rb +53 -0
- data/lib/axn/internal/shape_graph.rb +485 -0
- data/lib/axn/internal/subfield_tree.rb +108 -0
- data/lib/axn/internal/text.rb +75 -0
- data/lib/axn/internal/tracing.rb +82 -17
- data/lib/axn/mountable/mounting_strategies/_base.rb +4 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +111 -13
- data/lib/axn/mountable/mounting_strategies.rb +10 -2
- data/lib/axn/rails/engine.rb +15 -0
- data/lib/axn/result.rb +115 -14
- data/lib/axn/rubocop.rb +1 -0
- data/lib/axn/strategies.rb +10 -2
- data/lib/axn/testing/spec_helpers.rb +16 -0
- data/lib/axn/testing.rb +52 -0
- data/lib/axn/tools/adapter_roots.rb +36 -0
- data/lib/axn/tools/invoker.rb +52 -0
- data/lib/axn/tools/registry.rb +434 -0
- data/lib/axn/tools/version_group.rb +38 -0
- data/lib/axn/tools.rb +167 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +28 -8
- data/lib/rubocop/cop/axn/README.md +52 -0
- data/lib/rubocop/cop/axn/ambient_context_bypass.rb +90 -0
- metadata +55 -42
- data/.cursor/rules/axn-framework-patterns.mdc +0 -43
- data/.cursor/rules/general-coding-standards.mdc +0 -27
- data/.cursor/rules/spec/testing-patterns.mdc +0 -40
- data/CONTRIBUTING.md +0 -37
- data/Rakefile +0 -221
- data/docs/.vitepress/config.mjs +0 -79
- data/docs/advanced/conventions.md +0 -38
- data/docs/advanced/mountable.md +0 -506
- data/docs/advanced/profiling.md +0 -351
- data/docs/advanced/rough.md +0 -45
- data/docs/index.md +0 -36
- data/docs/intro/about.md +0 -46
- data/docs/intro/overview.md +0 -154
- data/docs/recipes/memoization.md +0 -131
- data/docs/recipes/rubocop-integration.md +0 -106
- data/docs/recipes/suppressing-duplicate-async-reports.md +0 -65
- data/docs/recipes/testing.md +0 -94
- data/docs/recipes/validating-user-input.md +0 -11
- data/docs/reference/async/active-job.md +0 -283
- data/docs/reference/async/sidekiq.md +0 -235
- data/docs/reference/async.md +0 -443
- data/docs/reference/axn-result.md +0 -107
- data/docs/reference/class.md +0 -679
- data/docs/reference/configuration.md +0 -622
- data/docs/reference/form-object.md +0 -252
- data/docs/reference/instance.md +0 -39
- data/docs/strategies/client.md +0 -212
- data/docs/strategies/form.md +0 -244
- data/docs/strategies/index.md +0 -272
- data/docs/strategies/transaction.md +0 -30
- data/docs/usage/setup.md +0 -42
- data/docs/usage/steps.md +0 -335
- data/docs/usage/using.md +0 -68
- data/docs/usage/writing.md +0 -427
- data/lib/axn/context.rb +0 -52
- data/lib/axn/core/validation/subfields.rb +0 -65
- data/lib/axn/executor.rb +0 -460
- data/lib/axn/extension_config.rb +0 -13
- data/lib/axn/internal/piping_error.rb +0 -33
- data/lib/axn/internal/subfield_path.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7c950f2b3d95c1f76aa0d963531b635e08c57058a2c3e2c8d07ccd40355faa5
|
|
4
|
+
data.tar.gz: 459ab003e2d8802c4c547ff6b89f6de3cc673e7e50072a66928d6a1b78444db7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8917cfa50c90daf33a41ebc40c90e5bec9bafe926da10a70ac4b73c34d5883185569af921d8f7693dd4f0b38074d07d675ecc8d3f7b4808be4b69ef8e52c6b9
|
|
7
|
+
data.tar.gz: 96a0838272f6f800206d2c85beae9c82f1e1ef954db7d940d856c431719870639bc4e63f1ad1883621665ac67eaa676bbc270c1072c0bf517d458e268d6790ef
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# RuboCop cops are not loaded by default in this repo
|
|
2
|
+
# Downstream consumers can enable them by adding:
|
|
3
|
+
# require:
|
|
4
|
+
# - axn/rubocop
|
|
5
|
+
|
|
6
|
+
# Merge with (rather than replace) RuboCop's built-in AllCops/Exclude — 'node_modules/**/*',
|
|
7
|
+
# 'tmp/**/*', 'vendor/**/*', '.git/**/*'. Declaring an Exclude array replaces those built-ins, and
|
|
8
|
+
# every glob resolves relative to the config file that declares it, so without this a gem doing
|
|
9
|
+
# `inherit_gem: axn` ends up with nothing excluding its OWN tree: it inherits globs pointing into the
|
|
10
|
+
# axn gem dir, having lost the built-ins that pointed at itself. CI then lints the bundle that
|
|
11
|
+
# ruby/setup-ruby's bundler-cache installs into an in-repo vendor/bundle, reading each vendored gem's
|
|
12
|
+
# own .rubocop.yml and dying on the plugins those `require:` but the consuming gem doesn't bundle.
|
|
13
|
+
# `inherit_mode` declared HERE governs the merge for every consumer, so they inherit the built-in
|
|
14
|
+
# excludes whether or not their own config remembers to re-declare them.
|
|
15
|
+
inherit_mode:
|
|
16
|
+
merge:
|
|
17
|
+
- Exclude
|
|
18
|
+
|
|
19
|
+
AllCops:
|
|
20
|
+
TargetRubyVersion: 3.2
|
|
21
|
+
SuggestExtensions: false
|
|
22
|
+
NewCops: enable
|
|
23
|
+
# Deltas only — vendor/node_modules/tmp/.git arrive via the merge above. The built-in globs are
|
|
24
|
+
# top-level-anchored, so a nested bundle (a locally-vendored dummy app) still needs naming.
|
|
25
|
+
Exclude:
|
|
26
|
+
- 'benchmark/**/*'
|
|
27
|
+
- 'bin/benchmark'
|
|
28
|
+
- 'spec_rails/dummy_app/vendor/**/*'
|
|
29
|
+
- 'spec_rails/dummy_app/tmp/**/*'
|
|
30
|
+
|
|
31
|
+
Style/MultilineBlockChain:
|
|
32
|
+
Enabled: false
|
|
33
|
+
|
|
34
|
+
Style/SingleLineMethods:
|
|
35
|
+
Enabled: false
|
|
36
|
+
|
|
37
|
+
Style/StringLiterals:
|
|
38
|
+
Enabled: true
|
|
39
|
+
EnforcedStyle: double_quotes
|
|
40
|
+
|
|
41
|
+
Style/Documentation:
|
|
42
|
+
Enabled: false
|
|
43
|
+
|
|
44
|
+
Style/TrailingCommaInArguments:
|
|
45
|
+
EnforcedStyleForMultiline: comma
|
|
46
|
+
|
|
47
|
+
Style/TrailingCommaInArrayLiteral:
|
|
48
|
+
EnforcedStyleForMultiline: comma
|
|
49
|
+
|
|
50
|
+
Style/TrailingCommaInHashLiteral:
|
|
51
|
+
EnforcedStyleForMultiline: comma
|
|
52
|
+
|
|
53
|
+
Style/ClassAndModuleChildren:
|
|
54
|
+
Enabled: false
|
|
55
|
+
|
|
56
|
+
Style/HashSyntax:
|
|
57
|
+
EnforcedShorthandSyntax: always
|
|
58
|
+
|
|
59
|
+
Naming/MemoizedInstanceVariableName:
|
|
60
|
+
Enabled: false
|
|
61
|
+
|
|
62
|
+
Style/DoubleNegation:
|
|
63
|
+
Enabled: false
|
|
64
|
+
|
|
65
|
+
Metrics/BlockLength:
|
|
66
|
+
Enabled: false
|
|
67
|
+
|
|
68
|
+
Metrics/ModuleLength:
|
|
69
|
+
Enabled: false
|
|
70
|
+
|
|
71
|
+
Metrics/ClassLength:
|
|
72
|
+
Max: 250
|
|
73
|
+
Exclude:
|
|
74
|
+
- 'spec_rails/dummy_app/lib/integration/**/*'
|
|
75
|
+
|
|
76
|
+
Metrics/MethodLength:
|
|
77
|
+
Max: 70
|
|
78
|
+
|
|
79
|
+
Metrics/PerceivedComplexity:
|
|
80
|
+
Max: 20
|
|
81
|
+
|
|
82
|
+
Metrics/AbcSize:
|
|
83
|
+
Max: 60
|
|
84
|
+
|
|
85
|
+
Metrics/CyclomaticComplexity:
|
|
86
|
+
Max: 20
|
|
87
|
+
|
|
88
|
+
Lint/EmptyBlock:
|
|
89
|
+
Enabled: false
|
|
90
|
+
|
|
91
|
+
Naming/MethodParameterName:
|
|
92
|
+
AllowedNames: e, on, id, if, as
|
|
93
|
+
|
|
94
|
+
Metrics/ParameterLists:
|
|
95
|
+
Max: 11
|
|
96
|
+
|
|
97
|
+
Layout/LineLength:
|
|
98
|
+
Max: 160
|
|
99
|
+
|
|
100
|
+
Naming/VariableNumber:
|
|
101
|
+
Exclude:
|
|
102
|
+
- 'spec/**/*'
|
|
103
|
+
|
|
104
|
+
# Sidekiq::RetrySet doesn't have empty? method
|
|
105
|
+
Style/ZeroLengthPredicate:
|
|
106
|
+
Exclude:
|
|
107
|
+
- 'spec_rails/dummy_app/lib/integration/**/*'
|
|
108
|
+
|
|
109
|
+
|
data/AGENTS-consuming.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Axn — agent guide
|
|
2
|
+
|
|
3
|
+
For an LLM writing code that **calls** or **declares** Axn actions (not modifying Axn internals).
|
|
4
|
+
Covers the contract, result/failure behavior, idioms, and gotchas; points out to the docs and source
|
|
5
|
+
for depth. On an edge case, read the source — paths below, via `bundle show axn`.
|
|
6
|
+
Docs: <https://teamshares.github.io/axn/>.
|
|
7
|
+
|
|
8
|
+
## Mental model
|
|
9
|
+
|
|
10
|
+
An Axn (Action) is a callable service object with a declared contract: inbound fields (`expects`),
|
|
11
|
+
outbound fields (`exposes`), and a `call` body. `Foo.call(...)` **always** returns an `Axn::Result`
|
|
12
|
+
— it never raises for ordinary failures (exceptions are swallowed and reported). You branch on
|
|
13
|
+
`result.ok?`. Business logic aborts with `fail!`; unexpected errors raise and are caught for you.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
class CreateWidget
|
|
17
|
+
include Axn
|
|
18
|
+
|
|
19
|
+
expects :name, type: String
|
|
20
|
+
expects :category, type: String, optional: true
|
|
21
|
+
exposes :widget
|
|
22
|
+
|
|
23
|
+
# Idiom: set meaningful messages — the generic fallbacks ("Action completed successfully" /
|
|
24
|
+
# "Something went wrong") are almost never what a caller should see. Do this by default.
|
|
25
|
+
success "Widget created"
|
|
26
|
+
error "Couldn't create widget"
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
fail!("Name is taken") if Widget.exists?(name:)
|
|
30
|
+
expose widget: Widget.create!(name:, category:)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result = CreateWidget.call(name: "Sprocket")
|
|
35
|
+
result.ok? # => true
|
|
36
|
+
result.widget # => #<Widget ...> (reader for the exposed field)
|
|
37
|
+
result.success # => "Widget created"
|
|
38
|
+
|
|
39
|
+
result = CreateWidget.call(name: "Existing") # name taken → fail!
|
|
40
|
+
result.ok? # => false
|
|
41
|
+
result.error # => "Couldn't create widget: Name is taken" (base headline prefixes the reason)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each declared field gets a reader **inside** the action (`name`, `category`) and each exposed field
|
|
45
|
+
gets a reader **on the result** (`result.widget`). Inside `call` you never touch a raw params hash —
|
|
46
|
+
you read the named fields.
|
|
47
|
+
|
|
48
|
+
## Declaring the contract
|
|
49
|
+
|
|
50
|
+
`expects` declares inbound fields; `exposes` declares outbound. Both take one or more field names
|
|
51
|
+
plus shared options. Validation runs automatically (inbound before `call`, outbound after).
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
expects :email, type: String
|
|
55
|
+
expects :role, inclusion: { in: %w[admin member] }, optional: true
|
|
56
|
+
exposes :user
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Common options (same for `expects` and `exposes`):
|
|
60
|
+
|
|
61
|
+
| Option | Meaning |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `type:` | `is_a?` check. `type: :boolean` (no Ruby Boolean class; also defines a `field?` predicate), `type: :uuid`, `type: :params` (a Hash or any `ActionController::Parameters`). Union: `type: [String, Symbol]`. |
|
|
64
|
+
| `optional: true` | Don't fail when the field is missing or nil (≡ `allow_blank: true`); removes the auto presence check. **Preferred** spelling. Caveat: a *typed* field still type-checks a non-nil blank — `type: Hash, optional: true` still rejects `""` (a `type: String` field accepts it, since `"".is_a?(String)`). |
|
|
65
|
+
| `allow_empty: true` | Accept an empty collection or string but **not** `nil` — the field stays required. Needs a `type:` whose values can be empty — `Array`/`Hash`/`Set`/`String`/`:params`, or any class or module defining `empty?`; raises otherwise, as does any value other than `true`/`false`/`nil`. Pair with a tolerance flag inverted (`optional: true, allow_empty: false`) for "may be omitted, but not empty". Don't also declare `presence:` — the two answer the same question, and a disagreement raises. |
|
|
66
|
+
| `allow_nil:` / `allow_blank:` | Finer-grained than `optional:`. |
|
|
67
|
+
| `default:` | Used when the field is missing or explicitly `nil` (**not** for blank values). |
|
|
68
|
+
| `sensitive: true` | Filter the value in logs / error reports / `inspect`. Accepts a proc/symbol for runtime decisions. |
|
|
69
|
+
| `of:` | For `type: Array` **only** — validates each element's class (`of: String`, `of: [String, Numeric]`). Errors report the failing index. |
|
|
70
|
+
| `validate:` | Custom: `validate: ->(v) { "must be > 10" unless v > 10 }` — return a string (or raise) to fail. |
|
|
71
|
+
| any ActiveModel validation | e.g. `length:`, `format:`, `numericality:` — passed through as if to `validates`. |
|
|
72
|
+
|
|
73
|
+
`expects`-only extras: `model:` (auto-hydrate a record, below), `on:` (subfields, below),
|
|
74
|
+
`as:`/`prefix:` (rename the reader), `preprocess:` (coerce before validation/defaults),
|
|
75
|
+
`user_facing:` (blame the caller, see Failure semantics).
|
|
76
|
+
|
|
77
|
+
These validations are the **developer contract** (how the action is called) — not pretty
|
|
78
|
+
user-facing copy. For user-facing input validation reach for `use :form`. Full option detail:
|
|
79
|
+
<https://teamshares.github.io/axn/reference/class>.
|
|
80
|
+
|
|
81
|
+
If this action runs as a tool (via `Axn::Tools::Invoker`, an adapter's runtime for model-supplied
|
|
82
|
+
args), declare a `type:` on every field rather than a defensive per-field `coerce: true` — tool
|
|
83
|
+
calls always coerce, and coercion plus schema reflection (`input_schema`) both key off `type:`.
|
|
84
|
+
See <https://teamshares.github.io/axn/reference/tool-invoker>.
|
|
85
|
+
|
|
86
|
+
## Inside `call`
|
|
87
|
+
|
|
88
|
+
| Helper | Effect |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `expose key: val` / `expose :key, val` | Set an exposed field on the result. Only declared `exposes` keys are allowed. |
|
|
91
|
+
| `fail!("msg", **kw)` | Abort now as a **failure**; `result.error` = msg; optional kwargs exposed first. |
|
|
92
|
+
| `done!("msg", **kw)` | Abort now as **success** (early return); skips remaining `call` + `after` hooks. |
|
|
93
|
+
| `log("msg", level: :info)` | Log via `Axn.config.logger`, prefixed with the class name. |
|
|
94
|
+
| field readers | Read any `expects` field by name; `result.<field>` reads exposures (rare inside `call`). |
|
|
95
|
+
|
|
96
|
+
If you declare `exposes :x` you must `expose x: …` on every success path — **unless** `x` is also an
|
|
97
|
+
`expects` field, in which case Axn auto-copies it (see Gotchas). Outbound validation still runs on
|
|
98
|
+
`done!`, so a required exposure that's unset makes the action fail with `OutboundValidationError`.
|
|
99
|
+
|
|
100
|
+
Hooks: `before`, `after`, `around` (block or symbol method). A `fail!`/raise in a hook fails the
|
|
101
|
+
action. `done!` skips `after` hooks but lets `around` finish. Callbacks (`on_success`, `on_error`,
|
|
102
|
+
`on_failure`, `on_exception`) run *after* `call` and do **not** flip `ok?`.
|
|
103
|
+
<https://teamshares.github.io/axn/usage/writing>.
|
|
104
|
+
|
|
105
|
+
## Using a result
|
|
106
|
+
|
|
107
|
+
`Axn::Result` is uniform across every action:
|
|
108
|
+
|
|
109
|
+
| Member | Meaning |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `ok?` | Succeeded? |
|
|
112
|
+
| `error` | User-facing error string when `!ok?` (else nil). |
|
|
113
|
+
| `success` / `message` | Success string when `ok?`; `message` is always set (success or error). |
|
|
114
|
+
| `outcome` | String inquirer: `outcome.success?` / `failure?` / `exception?`. |
|
|
115
|
+
| `exception` | The swallowed exception, if any (mostly for tests/diagnostics). |
|
|
116
|
+
| `<exposed field>` | Reader for each declared exposure. |
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
result = Actions::Slack::Post.call(channel: "#eng", message: text)
|
|
120
|
+
if result.ok?
|
|
121
|
+
@thread_id = result.thread_id
|
|
122
|
+
else
|
|
123
|
+
flash[:alert] = result.error
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Invocation variants:
|
|
128
|
+
|
|
129
|
+
- `Foo.call(**kw)` → always returns a `Result`; exceptions swallowed. **Default.**
|
|
130
|
+
- `Foo.call!(**kw)` → returns a `Result` on success; a `fail!` raises `Axn::Failure`, any other
|
|
131
|
+
error re-raises as-is. Use in scripts / when you want failures to bubble.
|
|
132
|
+
- `Foo.call_async(**kw)` → enqueue as a background job (configure with `async :sidekiq` /
|
|
133
|
+
`async :active_job`). See <https://teamshares.github.io/axn/reference/async>.
|
|
134
|
+
|
|
135
|
+
`Result` supports pattern matching (`in ok: true, user: User => u`).
|
|
136
|
+
|
|
137
|
+
## Field resolvers (`model:` and `on:`)
|
|
138
|
+
|
|
139
|
+
**`model:` — hydrate a record from an id.** `expects :user, model: true` adds expectations that
|
|
140
|
+
`user_id` is supplied (derived from the field name) and `User.find(user_id)` returns a record, and
|
|
141
|
+
defines both a `user` reader (the record) and a `user_id` reader. Variants: `model: User`,
|
|
142
|
+
`model: { klass: User, finder: :find_by_slug }`.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
expects :user, model: true
|
|
146
|
+
# called with user_id: 5 → user_id == 5, user resolves the record
|
|
147
|
+
# called with user: <rec> → user_id == rec.id, user is that record
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`user_id` always means *the record's primary key*, on every path. Passing both a record and a
|
|
151
|
+
disagreeing `user_id` (default `:find` finder) raises `InboundValidationError` — contradictory
|
|
152
|
+
input is a developer error. Source: `lib/axn/core/field_resolvers/model.rb`.
|
|
153
|
+
|
|
154
|
+
**`on:` — subfields (the `:extract` resolver).** Declare expectations about nested data and get a
|
|
155
|
+
flat reader:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
expects :event, type: Hash
|
|
159
|
+
expects :data, type: Hash, on: :event
|
|
160
|
+
expects :id, :type, on: :data # readers: id, type (extract event[:data][:id], ...)
|
|
161
|
+
expects :zip, on: "address.billing" # dotted path; reader: zip
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Subfields support all the normal options and `default:`; `readers: false` skips reader creation;
|
|
165
|
+
`as:`/`prefix:` rename. `default:`/`preprocess:`/`sensitive:` are **not** allowed on a *nested
|
|
166
|
+
parent*. Subfield hashes accept string **or** symbol keys (indifferent). Source:
|
|
167
|
+
`lib/axn/core/field_resolvers/extract.rb`. Reference:
|
|
168
|
+
<https://teamshares.github.io/axn/reference/class>.
|
|
169
|
+
|
|
170
|
+
## Failure semantics (read this — most subtle bugs live here)
|
|
171
|
+
|
|
172
|
+
Every non-success outcome lands in exactly one bucket:
|
|
173
|
+
|
|
174
|
+
| How it ends | `outcome` | `on_failure` | `on_exception` + **global report** | `result.exception` |
|
|
175
|
+
| --- | --- | --- | --- | --- |
|
|
176
|
+
| `fail!("…")` | `failure` | fires | **no** | `Axn::Failure` |
|
|
177
|
+
| `fails_on`-matched raise | `failure` | fires | **no** | the original exception |
|
|
178
|
+
| any other raised error | `exception` | — | **yes** (e.g. Honeybadger) | the original exception |
|
|
179
|
+
|
|
180
|
+
So `fail!` is for **expected, user-facing** outcomes; an unhandled raise is treated as a **bug** and
|
|
181
|
+
reported to `Axn.config.on_exception`. Key consequences:
|
|
182
|
+
|
|
183
|
+
- **`fails_on ExceptionClass`** reclassifies a raised exception from *bug* to *expected failure*
|
|
184
|
+
(fires `on_failure`, skips the global report, keeps the original on `result.exception`). Put it on
|
|
185
|
+
the action that **raises** the exception — it doesn't suppress a report from a deeper action. Only
|
|
186
|
+
reclassify deterministic/non-transient errors (e.g. `ActiveRecord::RecordInvalid`), never a
|
|
187
|
+
transient one you'd want retried. In async, a `fails_on` failure is terminal (no retry).
|
|
188
|
+
`result.error` never defaults to the exception's own (technical) `#message`; opt a specific class
|
|
189
|
+
in with `fails_on ExceptionClass, &:message` when that message is genuinely user-facing. This is
|
|
190
|
+
the idiom for the "save an ActiveRecord model" case — a plain action plus
|
|
191
|
+
`fails_on ActiveRecord::RecordInvalid, &:message` surfaces the record's validation errors as the
|
|
192
|
+
failure message (and, e.g., `fails_on Stripe::CardError, &:message` for a card-declined message).
|
|
193
|
+
- **`expects` violations are dev-facing by default** → exception bucket, pages, generic
|
|
194
|
+
`"Something went wrong"`. A missing required input is your bug. Mark a genuinely caller-supplied
|
|
195
|
+
field `user_facing: true` (or a String/Symbol/Proc message) to move *its* violations to the
|
|
196
|
+
failure bucket with a meaningful `result.error`. The field stays required. In a mixed failure
|
|
197
|
+
(a `user_facing:` field *and* a plain one), dev-facing wins and it still pages.
|
|
198
|
+
- **A nested bug is reported once**, from the innermost action that treats it as a bug, however deep
|
|
199
|
+
the `call!` chain.
|
|
200
|
+
|
|
201
|
+
**Messages — declare `success` and `error` by default.** The fallbacks are the generic
|
|
202
|
+
`"Action completed successfully"` / `"Something went wrong"`; declare a meaningful `success "…"` and
|
|
203
|
+
`error "…"` on every action whose result a caller surfaces. Both accept a string, a symbol (action
|
|
204
|
+
method), or a block (evaluated in instance context: `error { "Failed for #{name}" }`).
|
|
205
|
+
|
|
206
|
+
**Base/reason model.** An *unconditional* `error "Headline"` is the **base**: it's the fallback and it
|
|
207
|
+
auto-prefixes every failure reason as `"Headline: reason"` (a conditional `error … if:`, a
|
|
208
|
+
`prefixed: true` entry, and `fail!` strings). A *conditional* `error "…", if: SomeError` is a reason.
|
|
209
|
+
Most-recently-declared matching reason wins. `success`/`done!` work the same. A literal and a block
|
|
210
|
+
behave identically — conditionality (not string-vs-block) sets the role.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
error "Couldn't sync user" # base / fallback + prefix
|
|
214
|
+
error "email already taken", if: ArgumentError # reason → "Couldn't sync user: email already taken"
|
|
215
|
+
fail! "missing field" # reason → "Couldn't sync user: missing field"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Composing actions: a base `error` on the parent auto-prefixes a child failure surfaced via `call!`
|
|
219
|
+
**only when the child failed via `fail!`** (re-raised as `Axn::Failure`). A child `fails_on`-matched
|
|
220
|
+
exception — or any raised exception — bubbles as the *original* exception, so the parent settles as a
|
|
221
|
+
failure but `result.error` shows just the parent headline; the child's message is **not** woven in.
|
|
222
|
+
To carry the child's message (e.g. a `RecordInvalid`/model-strategy child), or to add per-call
|
|
223
|
+
context, use non-bang `call` + `fail!("context: #{child.error}")`.
|
|
224
|
+
|
|
225
|
+
⚠️ **Message bodies are NOT redacted** and propagate outward to every ancestor's `result.error`,
|
|
226
|
+
logs, and error trackers. Never interpolate secrets/PII into `error`/`success`/`fail!` text — put
|
|
227
|
+
sensitive values in `sensitive:` fields. Detail:
|
|
228
|
+
<https://teamshares.github.io/axn/usage/writing#prefixing-failure-reasons>. Source:
|
|
229
|
+
`lib/axn/core/flow/messages.rb`, `lib/axn/core/flow/fails_on.rb`.
|
|
230
|
+
|
|
231
|
+
## Gotchas
|
|
232
|
+
|
|
233
|
+
- **Indifferent access is top-level only.** Declared keys, call-arg keys, and `expose` keys are all
|
|
234
|
+
symbolized, so `expects :note` matches `.call("note" => x)` (the `.call(**params)` case). But field
|
|
235
|
+
**values** — including nested hashes — are untouched: reach into a nested value with the key type
|
|
236
|
+
it actually has (or declare an `on:` subfield, which *is* indifferent).
|
|
237
|
+
- **Auto-copy of `expects` + `exposes` fields.** A field declared with *both* is copied from input to
|
|
238
|
+
result automatically on **all** paths — success, `done!`, `fail!`, and exception. Lets a caller
|
|
239
|
+
read `result.user.errors` after a failed save without a manual `expose`. Don't redundantly
|
|
240
|
+
`expose` it.
|
|
241
|
+
- **`done!` rolls back a manual `ActiveRecord::Base.transaction`** (it's implemented via an
|
|
242
|
+
exception). Use `use :transaction` for transaction-safe early completion.
|
|
243
|
+
- **Default `call`.** Omit `call` entirely and Axn synthesizes one that exposes each declared
|
|
244
|
+
`exposes` by calling a same-named method. A method returning `nil` (no default) counts as missing.
|
|
245
|
+
- **`call` vs `call!`.** With `call!`, a `fail!` raises `Axn::Failure`; any *other* error re-raises
|
|
246
|
+
unchanged (not wrapped). `fails_on` reclassification is sticky across `call!` boundaries.
|
|
247
|
+
- **Hooks vs callbacks.** A raise/`fail!` in a `before`/`after`/`around` hook flips `ok?` to false; a
|
|
248
|
+
raise in a callback (`on_success` etc.) is reported but does **not** change `ok?`.
|
|
249
|
+
- **`sensitive:` proc timing.** For `expects`, the `sensitive:` callable runs *before* defaults are
|
|
250
|
+
applied — guard against `nil` if it depends on another field.
|
|
251
|
+
|
|
252
|
+
## Strategies (DRYed configuration via `use`)
|
|
253
|
+
|
|
254
|
+
- **`use :form do … end`** — validate user input via an `Axn::FormObject` (full ActiveModel
|
|
255
|
+
validations) before `call`; exposes `form`. For genuinely user-facing input.
|
|
256
|
+
<https://teamshares.github.io/axn/strategies/form>.
|
|
257
|
+
- **`use :transaction`** — wrap the action in a DB transaction that `done!` won't roll back.
|
|
258
|
+
- **`use :client`** (Faraday) for HTTP APIs.
|
|
259
|
+
|
|
260
|
+
## Composition (steps)
|
|
261
|
+
|
|
262
|
+
`step :name, expects: […], exposes: […] do … end` defines inline sequential steps; `steps(A, B, C)`
|
|
263
|
+
chains existing action classes. Data flows via the shared context; a step failure fails the parent
|
|
264
|
+
with the step name prefixed (`"validate: Email is invalid"`).
|
|
265
|
+
<https://teamshares.github.io/axn/usage/steps>.
|
|
266
|
+
|
|
267
|
+
## Pointers
|
|
268
|
+
|
|
269
|
+
Human docs — <https://teamshares.github.io/axn/>:
|
|
270
|
+
build (`/usage/writing`), use (`/usage/using`), class DSL (`/reference/class`), instance helpers
|
|
271
|
+
(`/reference/instance`), result (`/reference/axn-result`), strategies (`/strategies/`), steps
|
|
272
|
+
(`/usage/steps`), async (`/reference/async`), config (`/reference/configuration`), tool invoker
|
|
273
|
+
(`/reference/tool-invoker`).
|
|
274
|
+
|
|
275
|
+
Source entry points (resolve with `bundle show axn`):
|
|
276
|
+
- `lib/axn.rb` — `include Axn` wiring.
|
|
277
|
+
- `lib/axn/core/contract.rb` — `expects`/`exposes` declaration.
|
|
278
|
+
- `lib/axn/core/field_resolvers/` — `model.rb` (`model:`), `extract.rb` (`on:` subfields).
|
|
279
|
+
- `lib/axn/core/validation/validators/` — `type`, `of`, `model`, `validate`, `shape` validators.
|
|
280
|
+
- `lib/axn/core/flow/` — `messages.rb`, `fails_on.rb`, `handlers/` (failure/message/callback resolution).
|
|
281
|
+
- `lib/axn/result.rb`, `lib/axn/core/context/facade.rb` — the `Result` surface.
|
|
282
|
+
- `lib/axn/strategies/` — `model.rb`, `form.rb`, `transaction.rb`.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Axn — tool-adapter author guide
|
|
2
|
+
|
|
3
|
+
For an LLM writing an **axn tool-adapter gem**: a gem that exposes plain Axn actions over a tool/agent
|
|
4
|
+
transport (`axn-mcp` → MCP, `axn-ruby_llm` → RubyLLM function-calling, a hypothetical `axn-http_api` →
|
|
5
|
+
OpenAPI). Not for action authors — for *those* (declaring/calling Axns) read `AGENTS-consuming.md`, the
|
|
6
|
+
sibling in this gem. On an edge case, read the core source — paths below, via `bundle show axn`.
|
|
7
|
+
Docs: <https://teamshares.github.io/axn/recipes/authoring-tool-adapters>.
|
|
8
|
+
|
|
9
|
+
## Mental model
|
|
10
|
+
|
|
11
|
+
**Author-once.** A tool is a plain Axn (`include Axn` + `expects`/`exposes`/`call`) with nothing
|
|
12
|
+
transport-specific. The SAME class is wrapped by every adapter, called directly, or enqueued async. Your
|
|
13
|
+
adapter *projects* that class into the transport's native tool object, reading everything from Axn's public
|
|
14
|
+
reflection surface. Two hard rules:
|
|
15
|
+
|
|
16
|
+
- **Never require the author to write against your adapter.** No marker mixin, no adapter base class they
|
|
17
|
+
must subclass. They write a normal Axn; you wrap it.
|
|
18
|
+
- **Never mutate the shared class in a way that breaks a different adapter.** The class is wrapped by
|
|
19
|
+
others. Overriding `input_schema` to a non-Hash is the canonical bug that retired `Axn::MCP::Tool` — do
|
|
20
|
+
transport shaping inside `wrap`, not by redefining reflection on the class.
|
|
21
|
+
|
|
22
|
+
Core owns the shared machinery (membership, naming, reflection, config store, extension registry,
|
|
23
|
+
invocation contract); you consume its public API. Expose exactly two public methods so every adapter has
|
|
24
|
+
the same shape:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
GemName.tools # zero-arg: Axn::Tools.for(:key).map { |a| wrap(a) }
|
|
28
|
+
GemName.wrap(axn_class, **opts) # one Axn -> the transport's native tool object
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`.tools` MUST be zero-arg — so every `wrap` option must default (name/description from the class).
|
|
32
|
+
|
|
33
|
+
## Registration & discovery
|
|
34
|
+
|
|
35
|
+
- **Register at gem load, from the entry file:** `Axn::Tools.register_adapter(:key)`. Pass a config source
|
|
36
|
+
second arg (`Axn::Tools.register_adapter(:key, self)`) ONLY if you offer directory discovery; else omit.
|
|
37
|
+
Re-registering with no source is idempotent.
|
|
38
|
+
- **Enumerate with `Axn::Tools.for(:key)`** — returns the latest version per `tool_name`, sorted by
|
|
39
|
+
`tool_name` (a duplicate `(tool_name, tool_version)` raises), tool-root dirs eager-loaded first. Pass
|
|
40
|
+
`all_versions: true` for every version, or use `Axn::Tools.versions(:key, tool_name)` for one tool's group.
|
|
41
|
+
- **Only currently-loaded classes are enumerated.** A `tool :key` class outside a tool-root dir must be
|
|
42
|
+
`require`d first. Enumerate from `config.after_initialize` / `to_prepare` — **never** a
|
|
43
|
+
`config/initializers` file (runs before autoload paths are wired; `Axn::Tools.for` warns).
|
|
44
|
+
- **Membership** = `(directory grant ∪ declaration grant) − except`, computed by the registry. You don't
|
|
45
|
+
parse it — you call `Axn::Tools.for`. The author declares it: `tool` (all adapters), `tool :mcp` (add),
|
|
46
|
+
`tool mcp: { … }` / `configure(:mcp)` (implies `:mcp`), residency under a tool root, `tool false` (opt
|
|
47
|
+
out), `tool except: :x` (narrow).
|
|
48
|
+
- **Directory membership is optional.** `extend Axn::Tools::AdapterRoots` → a validated `tool_roots`
|
|
49
|
+
setting the registry reads. Its `validate!` reuses core's broad-path guard (rejects `app`/`actions`/`.`/`..`).
|
|
50
|
+
The reference gems don't adopt it (they use explicit `tool`/`configure`); add it only if it fits.
|
|
51
|
+
|
|
52
|
+
Source: `lib/axn/tools.rb` (`.register_adapter`, `.for`, `.versions`), `lib/axn/tools/registry.rb` (membership,
|
|
53
|
+
eager-load), `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` (`tool` DSL, `tool_name`).
|
|
54
|
+
|
|
55
|
+
## Naming & description
|
|
56
|
+
|
|
57
|
+
- **Name = `axn_class.tool_name(:your_key)` — pass your adapter key.** Don't roll your own — the same Axn
|
|
58
|
+
must yield the same name across adapters. It's provider-safe, never blank, honors `tool name:` and prefix
|
|
59
|
+
stripping. `Axn::Tools.for` sorts on `tool_name(:your_key)` and collapses to the latest per name (uniqueness is
|
|
60
|
+
on `(tool_name, tool_version)`); a per-adapter `tool your_key: { name: }` override is only returned when
|
|
61
|
+
you pass the key — the zero-arg form ignores per-adapter overrides, so reading it would publish a name
|
|
62
|
+
the registry didn't collapse on.
|
|
63
|
+
- **Description = `axn_class.description`.** `wrap`'s `description:` defaults to it (keeps `.tools` zero-arg).
|
|
64
|
+
|
|
65
|
+
## Schema reflection
|
|
66
|
+
|
|
67
|
+
- Use public `axn_class.input_schema` / `axn_class.output_schema` — plain JSON Schema **Hashes**. Wrap them
|
|
68
|
+
into your transport's schema object inside `wrap`.
|
|
69
|
+
- **Don't** reach into `Axn::Internal::Reflection::Schema` internals. **Don't** override `input_schema` to a non-Hash
|
|
70
|
+
(breaks other adapters on the shared class).
|
|
71
|
+
- `on: :ambient_context` fields are **auto-excluded** from `input_schema` — you get a clean model-facing
|
|
72
|
+
schema; don't re-add them.
|
|
73
|
+
- Reflection is best-effort, biased **stricter** than runtime (a schema-following call won't be rejected),
|
|
74
|
+
with one documented **looser** case (an invalid literal `default:`). Surface the caveat; don't fight it.
|
|
75
|
+
A deep subfield under a `model:`/non-object parent is omitted with a `logger.warn` — pass it through.
|
|
76
|
+
|
|
77
|
+
Source: `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema.rb`.
|
|
78
|
+
|
|
79
|
+
## Value serialization
|
|
80
|
+
|
|
81
|
+
- Render a success result's exposures with `Axn::Extensions::Serialization.render(result)` → JSON-safe Hash.
|
|
82
|
+
Don't hand-roll (it handles Symbol/BigDecimal/Time/`as_json`-vs-`to_h` so output matches `output_schema`).
|
|
83
|
+
- **You pass no config list** — `render` derives the declared `exposes` from the result itself. Rendering a
|
|
84
|
+
subset is deliberately unsupported: it would emit a body contradicting `output_schema`.
|
|
85
|
+
- **Don't reach into `Axn::Internal::Reflection::Values`.** `render` is the surface; the renderer's helpers are private, and
|
|
86
|
+
what stays public is there for core's own cross-module callers, not for you.
|
|
87
|
+
- No **value** in the result is one `JSON.generate` refuses (no non-finite number, no non-UTF-8 bytes, no
|
|
88
|
+
cycle, no collapsed property). That is a promise about values, NOT about your encoder's config: a structure
|
|
89
|
+
deeper than `max_nesting` (100 default) still raises `JSON::NestingError`. Drop your pre-*pass* over the
|
|
90
|
+
value graph; **keep** your encode `rescue`.
|
|
91
|
+
- Raises `Axn::Extensions::Serialization::UnserializableValue` (an `ArgumentError`), naming the path, on five
|
|
92
|
+
unconditional defects: a cycle; two exposed field NAMES that render as the same JSON property (compared the same
|
|
93
|
+
canonicalized way as a Hash key, since a declared field is itself a property name); two Hash keys that
|
|
94
|
+
render as one JSON property (compared as the PROPERTY each produces, not as the Ruby String its `to_s`
|
|
95
|
+
returned — keys are transcoded to UTF-8 first, so one property name in two encodings collides); a
|
|
96
|
+
non-finite Float (incl. a `BigDecimal`/`Rational` coercing to one); and a
|
|
97
|
+
String — or a key's String form — whose bytes have no UTF-8 rendering (stricter than `valid_encoding?`:
|
|
98
|
+
`"\xFF"` in `BINARY` is valid BINARY and unencodable; a valid ISO-8859-1/Shift_JIS value transcodes and
|
|
99
|
+
passes untouched, a key comes back as UTF-8). Add `reject_opaque: true` to also reject a
|
|
100
|
+
value or key that declares no rendering of its own — `to_s` inherited from `Object`, or (in Rails)
|
|
101
|
+
ActiveSupport's generic `Object#as_json` ivar dump as its only projection. A meaningful
|
|
102
|
+
`to_s`/`as_json`/`to_h` defined anywhere else passes, address-looking or not. Never write your own
|
|
103
|
+
pre-pass — it drifts from the renderer.
|
|
104
|
+
- Keep the two guarantees apart: encodability is unconditional, declared-shape is what the flag buys.
|
|
105
|
+
`reject_opaque: false` never means "might not be JSON" — that is why it isn't named `strict:`.
|
|
106
|
+
|
|
107
|
+
Source: `lib/axn/extensions/serialization.rb` (the renderer itself is `lib/axn/internal/reflection/values.rb`, core-internal).
|
|
108
|
+
|
|
109
|
+
## Per-adapter configuration
|
|
110
|
+
|
|
111
|
+
- `extend Axn::Configurable` + `config_namespace :key` (declare it before any overridable setting); declare
|
|
112
|
+
`setting :x, …, overridable: true`. See <https://teamshares.github.io/axn/recipes/gem-configuration>.
|
|
113
|
+
- **Resolve a per-class value with `Axn::<Mod>.resolve_override_for(axn_class, :x)` — NOT
|
|
114
|
+
`axn_class.public_send(:x)`.** A wrapped plain Axn never included your `overrides` module, so it has no
|
|
115
|
+
such accessor; but the app may have set the value via `configure(:key)` / `tool key: { … }`.
|
|
116
|
+
`resolve_override_for` is the shadow-proof reader over the override store.
|
|
117
|
+
- A **render toggle** (structured serialized `exposes` vs. the Axn's message) is a common per-adapter
|
|
118
|
+
setting. `axn-mcp` and `axn-ruby_llm` both name it `present_as` (`:structured` / `:message`) — reuse the
|
|
119
|
+
name/values if you have the concept. It's adapter-specific, not core (an `axn-http_api` has no such toggle).
|
|
120
|
+
|
|
121
|
+
Source: `lib/axn/configurable.rb` (`config_namespace`, `resolve_override_for`, `overrides`).
|
|
122
|
+
|
|
123
|
+
## Extension registry
|
|
124
|
+
|
|
125
|
+
- Add transport-only vocabulary without a core change: `Axn::Extensions.config.register_semantic_hint(:open_world,
|
|
126
|
+
:closed_world)` at load. Read `axn_class._semantic_hints` in `wrap` to map declared hints to your
|
|
127
|
+
annotations; let an explicit adapter override win. Hints are advisory (nothing enforces them).
|
|
128
|
+
|
|
129
|
+
Source: `lib/axn/extensions/config.rb`, `lib/axn/core/semantic_hints.rb`.
|
|
130
|
+
|
|
131
|
+
## Invocation & result → response
|
|
132
|
+
|
|
133
|
+
- **`axn_class.call(**kwargs)` returns an `Axn::Result` and never raises for a business failure** (`call!`
|
|
134
|
+
raises; `call` doesn't). Prefer calling through **`Axn::Tools::Invoker`** — it applies the tool contract
|
|
135
|
+
(always-on wire coercion, opt-in user-facing input-error surfacing, undeclared-key rejection, the
|
|
136
|
+
ambient guard) that a trusted in-process `.call` deliberately omits. See
|
|
137
|
+
<https://teamshares.github.io/axn/reference/tool-invoker>.
|
|
138
|
+
- Map from: `result.ok?`; `result.error` (**user-facing** — show to the LLM/client); `result.success` /
|
|
139
|
+
`result.message` (success string); `result.exception` (**dev-facing** detail, e.g. the
|
|
140
|
+
`Axn::InboundValidationError` — do **NOT** surface it).
|
|
141
|
+
- **`Axn::Extensions.owned_failure?(exception)`** — true for an axn-owned failure (`Axn::Failure` or a
|
|
142
|
+
user-facing validation error, whose `#message` is client-safe), false for a foreign exception
|
|
143
|
+
reclassified via `fails_on` (technical cause — don't leak). Check it before reading `#message` off an
|
|
144
|
+
exception.
|
|
145
|
+
- **Impose no gem-wide error headline.** Surface `result.error`; let each tool declare its own base
|
|
146
|
+
`error "…"`. A base `error` prefixes `fail!("reason")` as `"Headline: reason"` unless
|
|
147
|
+
`fail!("…", standalone: true)`.
|
|
148
|
+
- For per-field inbound detail: `Axn::Tools::Invoker.input_invalid?(result)` and
|
|
149
|
+
`result.exception.field_errors`.
|
|
150
|
+
|
|
151
|
+
Source: `lib/axn/extensions.rb` (`owned_failure?`), `lib/axn/tools/invoker.rb`, `lib/axn/result.rb`.
|
|
152
|
+
|
|
153
|
+
## Error boundary
|
|
154
|
+
|
|
155
|
+
- **Root your gem's errors at `Axn::Error`.** Give the gem a base class that `include`s the module, and
|
|
156
|
+
subclass every specific error from it: `class Axn::Webhooks::Error < StandardError; include Axn::Error;
|
|
157
|
+
end`. Two sibling gems already have such a base and need only the `include`.
|
|
158
|
+
- **It's a module, not a base class, on purpose.** `rescue` matches a module via `is_a?`, so including it
|
|
159
|
+
costs a class no ancestry — the adapter keeps whatever superclass its ecosystem needs (`< Faraday::Error`,
|
|
160
|
+
`< Timeout::Error`) and is still `rescue Axn::Error`-catchable. A base class would force a choice.
|
|
161
|
+
- **Including it is a promise, not decoration**: the class becomes public, documented, rescuable, and
|
|
162
|
+
breaking to remove.
|
|
163
|
+
- **The tag is inherited** — a tagged class cannot have an untagged subclass, so a public error family
|
|
164
|
+
can't grow a secretly-internal member.
|
|
165
|
+
- **Two things stay outside the boundary on purpose.** `Axn::Failure` is a control-flow signal from
|
|
166
|
+
`call!`, not a fault, so it is deliberately untagged — `rescue Axn::Error` around a `call!` should mean
|
|
167
|
+
"axn objected," not "axn objected or the action intentionally failed"; a caller wanting both writes
|
|
168
|
+
`rescue StandardError`. Generic `ArgumentError`s raised for DSL misuse (a malformed `on:`, an
|
|
169
|
+
unregistered tool adapter) also stay plain — that's this repo's convention for a declaration mistake, not
|
|
170
|
+
a runtime fault, and it isn't specific to what your adapter raises.
|
|
171
|
+
- **Why bother:** a consuming app's `rescue Axn::Error` catches core's errors AND every participating
|
|
172
|
+
adapter's, which is the payoff for following the convention instead of inventing a per-gem base with no
|
|
173
|
+
shared catch.
|
|
174
|
+
|
|
175
|
+
Source: `lib/axn/error.rb`.
|
|
176
|
+
|
|
177
|
+
## ambient_context
|
|
178
|
+
|
|
179
|
+
Server/session data (`current_user`, `company`) an author declares via `expects :user_id, on: :ambient_context`.
|
|
180
|
+
|
|
181
|
+
- **Spread it AS `ambient_context:`** — pass the injected context as the `ambient_context:` keyword, NOT
|
|
182
|
+
nested under an adapter key. Nesting couples the Axn to one adapter; spreading keeps it portable (the
|
|
183
|
+
same class resolves from an MCP server context, from `Current` on a direct call, or from ruby_llm). On a
|
|
184
|
+
direct `.call`, strip any model-supplied `ambient_context` from the args before splatting them (`**`) or
|
|
185
|
+
the model could override your trusted value — or just use the Invoker, which strips it for you.
|
|
186
|
+
- It's **filtered to declared keys**; the injected value must be a `Hash` / hash-like (responds to
|
|
187
|
+
`key?`/`[]`, e.g. `HashWithIndifferentAccess`) — axn keys into it and drops any source it can't key into,
|
|
188
|
+
so a bare opaque object resolves every ambient field as absent. Wrap a transport context object in a Hash
|
|
189
|
+
of the injected fields (keys string or symbol, indifferent).
|
|
190
|
+
- **Always pass an explicit `ambient_context:` (even `{}`)** — it *replaces* the `Current`-derived default
|
|
191
|
+
(no merge), preventing server-side state leaking into the call. The Invoker also strips any
|
|
192
|
+
`ambient_context` smuggled through model args before merging yours.
|
|
193
|
+
|
|
194
|
+
Source: `lib/axn/core/ambient_context.rb`, `lib/axn/tools/invoker.rb`.
|
|
195
|
+
|
|
196
|
+
## Live transport capabilities
|
|
197
|
+
|
|
198
|
+
- Progress/cancellation are **objects/operations, not ambient data** — they don't survive ambient_context
|
|
199
|
+
filtering. Expose them via an adapter handle scoped with `ActiveSupport::IsolatedExecutionState`
|
|
200
|
+
(thread-/fiber-scoped per the configured isolation level), matching how axn scopes its own per-execution
|
|
201
|
+
state. A raw `Thread.current[...]` local is wrong under a Fiber scheduler. See `Axn::MCP.server_context` /
|
|
202
|
+
`with_server_context` in axn-mcp.
|
|
203
|
+
|
|
204
|
+
## Inline / one-off tools
|
|
205
|
+
|
|
206
|
+
- **Don't ship a per-gem `define`.** Wrap a core `Axn::Factory.build`:
|
|
207
|
+
`GemName.wrap(Axn::Factory.build(expects:, exposes:, axn_name: "…", description: "…") { … })`. The block is
|
|
208
|
+
the `#call` body (keyword-only args; not available for `exposes`/`shape:` coercion). A factory-built
|
|
209
|
+
class is **not** auto-discovered by `Axn::Tools.for` (synthetic name) — the constructor holds the reference
|
|
210
|
+
and wraps it directly. See <https://teamshares.github.io/axn/reference/factory>.
|
|
211
|
+
|
|
212
|
+
## Deprecations
|
|
213
|
+
|
|
214
|
+
- Own a dedicated `ActiveSupport::Deprecation.new("1.0", "gem-name")` as `GemName.deprecator`, so a
|
|
215
|
+
consuming Rails app can register it (`Rails.application.deprecators[:gem] = GemName.deprecator`) and
|
|
216
|
+
govern its behavior. `axn-mcp` does this; `axn-ruby_llm` currently uses raw `warn` — follow axn-mcp.
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
- Reuse `Axn::Testing::SpecHelpers` (`build_axn { … }`, `with_ambient_context`) to construct the wrapped
|
|
221
|
+
Axns. Verify adapter output against **real** transport objects (a real `MCP::Tool::Response`/`InputSchema`,
|
|
222
|
+
a real `RubyLLM::Tool`), not hand-built hashes. **Pin the exact user-facing failure/success strings.** An
|
|
223
|
+
end-to-end spec driving a real `MCP::Server.new(tools:, server_context:)` catches wiring a unit test can't.
|
|
224
|
+
|
|
225
|
+
## Reference gems
|
|
226
|
+
|
|
227
|
+
- **axn-mcp** — adapter key `:mcp`; `wrap` → `::MCP::Tool` subclass → `MCP::Tool::Response`. Full worked
|
|
228
|
+
example of every convention above (registration, `server_context`, `semantic_hints` → annotations,
|
|
229
|
+
dedicated deprecator, real-object specs).
|
|
230
|
+
- **axn-ruby_llm** — adapter key `:ruby_llm`; `wrap` → `::RubyLLM::Tool`. No output schema, no
|
|
231
|
+
transport-capability handle, no semantic_hints (RubyLLM has no annotations) — a simpler adapter surface.
|
|
232
|
+
|
|
233
|
+
## Pointers
|
|
234
|
+
|
|
235
|
+
Docs — <https://teamshares.github.io/axn/>: authoring a tool-adapter gem
|
|
236
|
+
(`/recipes/authoring-tool-adapters`), tool invoker (`/reference/tool-invoker`), gem configuration
|
|
237
|
+
(`/recipes/gem-configuration`), factory (`/reference/factory`), class DSL (`/reference/class`), result
|
|
238
|
+
(`/reference/axn-result`). Action-authoring: `AGENTS-consuming.md` (this gem).
|
|
239
|
+
|
|
240
|
+
Core source entry points (resolve with `bundle show axn`):
|
|
241
|
+
- `lib/axn/tools.rb` — `Axn::Tools.register_adapter`, `.adapters`, `.for`, `.versions`, `.validate_contracts!`.
|
|
242
|
+
- `lib/axn/extensions.rb` — `Axn::Extensions.best_effort`, `.config`, `.owned_failure?` (extension-author surface).
|
|
243
|
+
- `lib/axn/tools/registry.rb`, `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` —
|
|
244
|
+
membership, `tool_name`.
|
|
245
|
+
- `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema.rb`, `lib/axn/internal/reflection/values.rb` — reflection.
|
|
246
|
+
- `lib/axn/configurable.rb` — `config_namespace`, `resolve_override_for`, `overrides`.
|
|
247
|
+
- `lib/axn/tools/invoker.rb` — the tool call path.
|
|
248
|
+
- `lib/axn/core/ambient_context.rb` — ambient filtering/resolution.
|
|
249
|
+
- `lib/axn/factory.rb` — `Axn::Factory.build`.
|
|
250
|
+
- `lib/axn/error.rb` — `Axn::Error`, the public-error boundary tag.
|