riffer 0.29.0 → 0.30.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/rbs-inline.md +51 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +18 -0
  5. data/README.md +1 -0
  6. data/Steepfile +2 -1
  7. data/docs/01_OVERVIEW.md +1 -0
  8. data/docs/03_AGENTS.md +1 -1
  9. data/docs/15_SERIALIZATION.md +103 -0
  10. data/lib/riffer/agent/config.rb +2 -2
  11. data/lib/riffer/agent/context.rb +2 -0
  12. data/lib/riffer/agent/response.rb +2 -0
  13. data/lib/riffer/agent/run.rb +2 -1
  14. data/lib/riffer/agent/serializer.rb +215 -0
  15. data/lib/riffer/agent/session.rb +2 -0
  16. data/lib/riffer/agent.rb +84 -18
  17. data/lib/riffer/evals/evaluator.rb +5 -0
  18. data/lib/riffer/evals/judge.rb +5 -0
  19. data/lib/riffer/mcp/client.rb +2 -0
  20. data/lib/riffer/mcp/registration.rb +4 -0
  21. data/lib/riffer/mcp/registry.rb +3 -0
  22. data/lib/riffer/messages/file_part.rb +2 -0
  23. data/lib/riffer/params/param.rb +84 -4
  24. data/lib/riffer/params.rb +34 -3
  25. data/lib/riffer/providers/amazon_bedrock.rb +28 -21
  26. data/lib/riffer/providers/anthropic.rb +13 -9
  27. data/lib/riffer/providers/base.rb +2 -0
  28. data/lib/riffer/providers/gemini.rb +4 -0
  29. data/lib/riffer/providers/mock.rb +4 -0
  30. data/lib/riffer/providers/open_ai.rb +10 -7
  31. data/lib/riffer/providers/open_router.rb +25 -18
  32. data/lib/riffer/runner/fibers.rb +2 -0
  33. data/lib/riffer/runner/threaded.rb +2 -0
  34. data/lib/riffer/skills/config.rb +5 -0
  35. data/lib/riffer/skills/context.rb +3 -0
  36. data/lib/riffer/skills/filesystem_backend.rb +3 -0
  37. data/lib/riffer/tools/response.rb +2 -0
  38. data/lib/riffer/tools/runtime.rb +2 -0
  39. data/lib/riffer/tools/toolable.rb +7 -0
  40. data/lib/riffer/version.rb +1 -1
  41. data/lib/riffer.rb +2 -0
  42. data/sig/_private/anthropic.rbs +16 -0
  43. data/sig/_private/openai.rbs +29 -0
  44. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -0
  45. data/sig/_private/riffer/providers/anthropic.rbs +4 -0
  46. data/sig/_private/riffer/providers/open_ai.rbs +4 -0
  47. data/sig/_private/riffer/providers/open_router.rbs +4 -0
  48. data/sig/generated/riffer/agent/config.rbs +3 -3
  49. data/sig/generated/riffer/agent/context.rbs +2 -0
  50. data/sig/generated/riffer/agent/response.rbs +2 -0
  51. data/sig/generated/riffer/agent/serializer.rbs +132 -0
  52. data/sig/generated/riffer/agent/session.rbs +2 -0
  53. data/sig/generated/riffer/agent.rbs +67 -11
  54. data/sig/generated/riffer/evals/evaluator.rbs +8 -0
  55. data/sig/generated/riffer/evals/judge.rbs +8 -0
  56. data/sig/generated/riffer/mcp/client.rbs +2 -0
  57. data/sig/generated/riffer/mcp/registration.rbs +6 -0
  58. data/sig/generated/riffer/mcp/registry.rbs +4 -0
  59. data/sig/generated/riffer/messages/file_part.rbs +2 -0
  60. data/sig/generated/riffer/params/param.rbs +46 -5
  61. data/sig/generated/riffer/params.rbs +25 -6
  62. data/sig/generated/riffer/providers/amazon_bedrock.rbs +20 -20
  63. data/sig/generated/riffer/providers/anthropic.rbs +10 -10
  64. data/sig/generated/riffer/providers/base.rbs +2 -0
  65. data/sig/generated/riffer/providers/gemini.rbs +6 -0
  66. data/sig/generated/riffer/providers/mock.rbs +6 -0
  67. data/sig/generated/riffer/providers/open_ai.rbs +8 -8
  68. data/sig/generated/riffer/providers/open_router.rbs +16 -16
  69. data/sig/generated/riffer/runner/fibers.rbs +2 -0
  70. data/sig/generated/riffer/runner/threaded.rbs +2 -0
  71. data/sig/generated/riffer/skills/config.rbs +8 -0
  72. data/sig/generated/riffer/skills/context.rbs +4 -0
  73. data/sig/generated/riffer/skills/filesystem_backend.rbs +4 -0
  74. data/sig/generated/riffer/tools/response.rbs +2 -0
  75. data/sig/generated/riffer/tools/runtime.rbs +2 -0
  76. data/sig/generated/riffer/tools/toolable.rbs +12 -0
  77. data/sig/generated/riffer.rbs +2 -0
  78. data/sig/manifest.yaml +3 -0
  79. data/sig/manual/riffer/agent/run.rbs +5 -0
  80. data/sig/manual/riffer/agent/serializer.rbs +5 -0
  81. data/sig/manual/riffer/helpers/call_or_value.rbs +5 -0
  82. data/sig/manual/riffer/tools/toolable.rbs +6 -0
  83. metadata +20 -11
  84. data/sig/stubs/agent_ivars.rbs +0 -7
  85. data/sig/stubs/extend_self.rbs +0 -11
  86. data/sig/stubs/lib_ivars.rbs +0 -101
  87. data/sig/stubs/provider_ivars.rbs +0 -36
  88. data/sig/stubs/provider_sdk_methods.rbs +0 -50
  89. /data/sig/{stubs → _private}/async.rbs +0 -0
  90. /data/sig/{stubs → _private}/aws-sdk-core/seahorse_request_context.rbs +0 -0
  91. /data/sig/{stubs → _private}/aws-sdk-core/static_token_provider.rbs +0 -0
  92. /data/sig/{stubs/mcp_sdk.rbs → _private/mcp.rbs} +0 -0
  93. /data/sig/{stubs → _private}/zeitwerk.rbs +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be73ae37c66f2f6ad16acdd7b52c271a782172878086821a9c95dbbe238541d3
4
- data.tar.gz: 29ea65859395f2e6daaa993d9e8c60cf1908781f1acf79b642588439f4e41781
3
+ metadata.gz: 7557c63ca04fa006d66a61a53bef6b1f7069ebab67439f00104fbb877d1aceda
4
+ data.tar.gz: 39bbe45b7e111ae343e32c54b9769db17dd7d8d9b98cd5cb7d42338aa5e4179c
5
5
  SHA512:
6
- metadata.gz: cce21dcb1840e39b96032cf6061dcce6030e35c21c1ce8f3937138405067cd39644f4a55fd4fddfcc9623549441dccb0d76b5443e7fa890ab84ce73f6af2736d
7
- data.tar.gz: 3c0e8a0fbcd42c3aceee9b4d8837188e260d444c2e22df72f1976bdfc7b05bf2669f2494d3121ca066d142baa8629bdd01c51acd294869702b5357d83943a4a6
6
+ metadata.gz: 61694198a05d63d831dca9b4b37e38b6cacbd7b67214fa47c2027a0a7e1636ee07e5f85641f9fe00ee597d66dfdb0a6fccd0e494b6ec670a84a2020907cfdd0b
7
+ data.tar.gz: fbff4457d39da36a1c77e9cb7ea86ccb5e96f0887ba982f6a8a960557ee4beceda3a604011fcfd20951f9baedd59eadbf188832cb6bcc8fcf71e256d93e8cdc3
@@ -98,6 +98,23 @@ VERSION = "1.0.0" #: String
98
98
  DEFAULTS = {}.freeze #: Hash[Symbol, untyped]
99
99
  ```
100
100
 
101
+ ### Instance variables
102
+
103
+ The `#:` shorthand on an assignment is a Steep _assertion_ — it types the expression but does
104
+ **not declare the ivar**. To declare an ivar's type, use a `# @rbs` comment inside the class
105
+ body. `# @rbs` is used **only** for ivar declarations in this codebase; everything else uses
106
+ `#:`.
107
+
108
+ ```ruby
109
+ class Riffer::Agent::Session
110
+ # @rbs @callbacks: Array[^(Riffer::Messages::Base) -> void] # instance ivar
111
+ end
112
+
113
+ module Riffer
114
+ # @rbs self.@config: Riffer::Config? # class/module-level ivar
115
+ end
116
+ ```
117
+
101
118
  ## Common Type Patterns
102
119
 
103
120
  | Pattern | Meaning |
@@ -112,6 +129,40 @@ DEFAULTS = {}.freeze #: Hash[Symbol, untyped]
112
129
  | `untyped` | Any type |
113
130
  | `void` | No meaningful return |
114
131
 
132
+ ## Optional-dependency types (consumer-safe signatures)
133
+
134
+ `sig/generated/` ships with the gem and is loaded by downstream projects (`rbs collection` / `rbs -r riffer`). rbs-inline copies a method's `#:` signature **verbatim** into the shipped sig, so **never name an optional-dependency type in a `#:` signature** — `OpenAI::*`, `Anthropic::*`, `Aws::*`, `MCP::*`, `Async::*`, `Zeitwerk::*`, etc. A consumer who installs riffer without that gem would hit `Cannot find type`, because those providers are pluggable and the gems ship no usable RBS of their own.
135
+
136
+ **Workaround — assert the type inside the method body instead.** An inline assertion (`local = arg #: OpenAI::Models::…`) is a Steep-only hint that rbs-inline does **not** emit into the signature. Leave the SDK param/return `untyped` in the `#:` line, keep every riffer/stdlib param and return typed, and recover the SDK type with a body assertion:
137
+
138
+ ```ruby
139
+ #: (untyped) -> String
140
+ def extract_content(response)
141
+ message = response #: Anthropic::Models::Message
142
+ message.content&.first&.text || "" # fully type-checked against the SDK type
143
+ end
144
+
145
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
146
+ def handle_stream_chunk(chunk, state:, yielder:)
147
+ typed = chunk #: OpenAI::Models::Chat::ChatCompletionChunk
148
+ # ...
149
+ end
150
+
151
+ # When the return value IS the SDK object, type the return `untyped` (no body assertion needed):
152
+ #: (Hash[Symbol, untyped]) -> untyped
153
+ def execute_generate(params)
154
+ @client.messages.create(**params)
155
+ end
156
+ ```
157
+
158
+ `test/shipped_signatures_test.rb` enforces this — it fails if any optional-dependency type appears in `sig/generated/` or `sig/manual/`.
159
+
160
+ ### Where stubs and stdlib deps live
161
+
162
+ - `sig/_private/` — signatures that must **not** ship. RBS **skips** `_`-prefixed directories in library mode, so consumers never load them; riffer's own `steep check` does (via the `Steepfile`). Two kinds, by predictable path: external-gem signatures are named by gem at the top level (`async.rbs`, `mcp.rbs`, `zeitwerk.rbs`, `openai.rbs`, `anthropic.rbs`, `aws-sdk-core/*` — full stubs for RBS-less gems plus arity patches for the provider SDKs); riffer's own hidden stubs mirror `lib/` under `riffer/` (e.g. `riffer/providers/anthropic.rbs` declares the SDK-typed `@client` ivar).
163
+ - `sig/manual/` — hand-written riffer-only signatures that are **safe to ship**, for the few things rbs-inline can't generate _at all_ (mirroring `lib/`). In practice that's `extend self` modules (`riffer/agent/run.rbs`, `riffer/helpers/call_or_value.rbs`) and modeling an include applied dynamically (`riffer/tools/toolable.rbs`). SDK-free ivars are **not** hand-written here — declare them inline with `# @rbs` (see "Instance variables"). SDK-typed ivars can't ship, so they go in `_private/riffer/providers/` (`@client`).
164
+ - `sig/manifest.yaml` — declares the **stdlib** RBS the shipped sigs reference (`uri`, `net-http`) so `rbs -r riffer` resolves them.
165
+
115
166
  ## Workflow
116
167
 
117
168
  After changing type annotations:
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.29.0"
2
+ ".": "0.30.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.30.0](https://github.com/janeapp/riffer/compare/riffer/v0.29.1...riffer/v0.30.0) (2026-06-03)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * unlimited max_steps is now represented as `nil` at the agent level (set via `max_steps nil`), not Float::INFINITY — Riffer::Agent::Config#max_steps may be nil and is no longer normalized. Riffer::Params parameter types are typed Module (widened from Class) to honestly include Riffer::Params::Boolean, which is a Module.
14
+
15
+ ### Features
16
+
17
+ * Riffer::Agent::Serializer for transferable agent definitions ([#293](https://github.com/janeapp/riffer/issues/293)) ([99134b0](https://github.com/janeapp/riffer/commit/99134b0bf52bccfd727e52349aba8432389775be))
18
+
19
+ ## [0.29.1](https://github.com/janeapp/riffer/compare/riffer/v0.29.0...riffer/v0.29.1) (2026-06-01)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **rbs:** ship consumer-safe RBS signatures ([#286](https://github.com/janeapp/riffer/issues/286)) ([ac8ce6c](https://github.com/janeapp/riffer/commit/ac8ce6c40ab665456cee6cd9275649024492f4a0))
25
+
8
26
  ## [0.29.0](https://github.com/janeapp/riffer/compare/riffer/v0.28.0...riffer/v0.29.0) (2026-05-29)
9
27
 
10
28
 
data/README.md CHANGED
@@ -61,6 +61,7 @@ For comprehensive documentation, see the [docs](docs/) directory:
61
61
  - [Guardrails](docs/12_GUARDRAILS.md) - Input/output validation
62
62
  - [Skills](docs/13_SKILLS.md) - Packaged agent capabilities
63
63
  - [MCP](docs/14_MCP.md) - Integrating third-party MCP servers
64
+ - [Serialization](docs/15_SERIALIZATION.md) - Persisting and transferring agent definitions
64
65
  - [Providers](docs/providers/01_PROVIDERS.md) - LLM provider adapters
65
66
 
66
67
  ### API Reference
data/Steepfile CHANGED
@@ -2,7 +2,8 @@ D = Steep::Diagnostic
2
2
 
3
3
  target :lib do
4
4
  signature "sig/generated"
5
- signature "sig/stubs"
5
+ signature "sig/manual"
6
+ signature "sig/_private"
6
7
 
7
8
  check "lib"
8
9
 
data/docs/01_OVERVIEW.md CHANGED
@@ -134,3 +134,4 @@ Response
134
134
  - [Evals](11_EVALS.md) - Evaluating agent quality
135
135
  - [Guardrails](12_GUARDRAILS.md) - Input/output validation
136
136
  - [Skills](13_SKILLS.md) - Packaged agent capabilities
137
+ - [Serialization](15_SERIALIZATION.md) - Persisting and transferring agent definitions
data/docs/03_AGENTS.md CHANGED
@@ -152,7 +152,7 @@ end
152
152
 
153
153
  ### max_steps
154
154
 
155
- Sets the maximum number of LLM call steps in the tool-use loop. When the limit is reached, the loop interrupts with reason `:max_steps`. Defaults to `16`. Set to `Float::INFINITY` for unlimited steps:
155
+ Sets the maximum number of LLM call steps in the tool-use loop. When the limit is reached, the loop interrupts with reason `:max_steps`. Defaults to `16`. Set to `nil` (`max_steps nil`) for unlimited steps:
156
156
 
157
157
  ```ruby
158
158
  class MyAgent < Riffer::Agent
@@ -0,0 +1,103 @@
1
+ # Serialization
2
+
3
+ `Riffer::Agent::Serializer` turns a **resolved agent** into a self-contained, provider-neutral data dict (`to_h`) and reconstructs a **runnable agent** from that dict (`from_h`). Use it to persist agent definitions outside of code, or to transfer them across a process/service boundary.
4
+
5
+ You normally reach it through the delegators on `Riffer::Agent`:
6
+
7
+ ```ruby
8
+ dict = agent.to_h # snapshot
9
+ rebuilt = Riffer::Agent.from_h(dict) # reconstruct
10
+ ```
11
+
12
+ The dict is plain data — symbol-keyed, JSON-safe. For the wire, use the JSON helpers, which handle generating and parsing for you:
13
+
14
+ ```ruby
15
+ json = agent.to_json # or Riffer::Agent::Serializer.to_json(agent:)
16
+ rebuilt = Riffer::Agent.from_json(json)
17
+ ```
18
+
19
+ The hash forms (`to_h` / `from_h`) are public too, if you want to embed the dict in a larger payload. `from_h` expects symbol keys, so parse with `JSON.parse(str, symbolize_names: true)` — or just use `from_json`, which does that for you.
20
+
21
+ ### Runtime context
22
+
23
+ `from_h` / `from_json` accept an optional `context:` — the rebuilt agent's **runtime** context, exactly the value you'd pass to `Agent.new(context:)`. It is **not** used to re-resolve the serialized definition (that's already resolved); it's threaded into tool dispatch and read by tools/runtimes at call time. Pass it when a tool or a remote runtime needs per-call data — e.g. `context: { tenant: "acme" }` for multi-tenant dispatch, or Maestro passing `context: { agent: self }` so its runtime can call back. Omit it (defaults to empty) when nothing downstream reads context.
24
+
25
+ ## What the dict carries
26
+
27
+ ```ruby
28
+ {
29
+ schema_version: 1, # wire format version
30
+ riffer_version: "0.29.1", # diagnostic only
31
+ identifier: "support_agent",
32
+ model: "openai/gpt-4o", # resolved "provider/model" string
33
+ instructions: "You are…", # resolved system prompt
34
+ model_options: { temperature: 0.2 },
35
+ provider_options: { … }, # see the secrets warning below
36
+ max_steps: 8, # integer; -1 = unlimited (see below)
37
+ structured_output: { type: "object", … }, # JSON Schema, or null
38
+ tools: [ { name:, description:, parameters_schema:, timeout: }, … ]
39
+ }
40
+ ```
41
+
42
+ ### Resolved snapshot
43
+
44
+ `to_h` reads the agent's **resolved** state, not its raw configuration. By the time you call it, `Agent.new` has already evaluated any `Proc`-based `model`, `instructions`, or `uses_tools` against the agent's own context — so the dict carries plain strings and data, never Procs. The receiver's `context:` drives runtime behavior (tool dispatch); it does **not** re-evaluate baked-in fields.
45
+
46
+ ### Structured output
47
+
48
+ Structured output crosses as **provider-neutral JSON Schema** (`Riffer::Params#to_json_schema`), never a provider-rendered schema. `from_h` rebuilds a validating `Riffer::Params` via `Riffer::Params.from_json_schema`, so the rebuilt agent both constrains the model and can `parse_and_validate` responses — rendering provider-correct bytes at call time, the same way an in-code agent does.
49
+
50
+ ## Reconstructing tools
51
+
52
+ Tools cross as `{name, description, parameters_schema, timeout}` descriptors — never code. How a descriptor becomes a runnable tool is controlled by the `tool_resolver:` you pass to `from_h`. The two common shapes:
53
+
54
+ ### In-process (registry lookup)
55
+
56
+ When the rebuilt agent runs in the **same** codebase that defined the tools (e.g. persisting an agent definition and rehydrating it later), resolve each descriptor back to its real class:
57
+
58
+ ```ruby
59
+ rebuilt = Riffer::Agent.from_h(dict,
60
+ tool_resolver: ->(descriptor) { MyToolRegistry.fetch(descriptor[:name]) })
61
+ ```
62
+
63
+ The real classes carry their `#call` bodies, so the agent runs on the default `Inline` runtime — no further wiring.
64
+
65
+ ### Distributed (body-less shells)
66
+
67
+ When the receiver holds **only the Riffer gem**, the default `tool_resolver` synthesizes body-less **tool shells**. A shell advertises the tool's schema to the LLM but has no `#call` — invoking it in-process raises. Pair the default resolver with a remote `Riffer::Tools::Runtime` that forwards each call back to the origin:
68
+
69
+ ```ruby
70
+ rebuilt = Riffer::Agent.from_h(dict,
71
+ tool_runtime: MyRemoteToolRuntime.new(client: rpc_client))
72
+ ```
73
+
74
+ Implement the remote runtime by subclassing `Riffer::Tools::Runtime` and overriding `#dispatch_tool_call` to forward the call over your transport, mapping any failure to `Tools::Response.error`. See [Advanced Tools](07_TOOL_ADVANCED.md) for the runtime API.
75
+
76
+ You own what a resolved tool does: a resolver may return real in-process classes, shells, or classes that themselves make network calls. Riffer does not require a runtime — it only ships shells by default.
77
+
78
+ ## `max_steps`
79
+
80
+ Unlimited steps are `nil` at the agent level — set it with `max_steps nil`. On the wire, the serializer encodes that as **`-1`** (and decodes `-1` back to `nil`), so the dict stays portable across transports where JSON `null` is awkward — proto3, for one, can't distinguish `null` from an absent field. The `-1` is purely a wire detail: the DSL and your code only ever see `nil`, and the encode/decode handles the translation at the boundary.
81
+
82
+ - **DSL** — integer = bounded, `nil` = unlimited, omitted = `Config`'s default (16).
83
+ - **Wire** — integer = bounded, `-1` = unlimited, omitted = default (16).
84
+
85
+ A finite integer round-trips as-is; a dict missing the key falls back to the default rather than running unbounded.
86
+
87
+ ## Versioning
88
+
89
+ `schema_version` is an integer (`Riffer::Agent::Serializer::SCHEMA_VERSION`). `from_h` refuses any version it doesn't recognize with `Riffer::Agent::Serializer::VersionError` (a `Riffer::ArgumentError`). A future incompatible change bumps the integer and adds a backwards-compatible decoder, giving distributed consumers a window to upgrade before the old format is dropped.
90
+
91
+ ## Secrets
92
+
93
+ `provider_options` and `model_options` **ride on the wire as plain data** — they are part of the dict and _will_ transfer. Prefer configuring API keys via environment/global provider configuration rather than `provider_options`. **Never serialize an agent whose options carry sensitive values** — and if a serialized definition ever does, handle it as a secret (encrypt it, keep it out of logs).
94
+
95
+ ## What does **not** transfer
96
+
97
+ - **Guardrails and skills** are **not supported yet** — neither is serialized, so a rebuilt agent enforces no guardrails and has no skills catalog. (As a stopgap, a skills-enabled agent's `skill_activate` tool still crosses as an ordinary tool descriptor.) Both are expected to be revisited.
98
+
99
+ ## Next Steps
100
+
101
+ - [Tools](06_TOOLS.md) - Creating tools
102
+ - [Advanced Tools](07_TOOL_ADVANCED.md) - Tool runtime and dispatch
103
+ - [Configuration](10_CONFIGURATION.md) - Global configuration
@@ -21,7 +21,7 @@ class Riffer::Agent::Config
21
21
  attr_accessor :provider_options #: Hash[Symbol, untyped]
22
22
  attr_accessor :model_options #: Hash[Symbol, untyped]
23
23
  attr_reader :structured_output #: Riffer::Params?
24
- attr_accessor :max_steps #: Numeric
24
+ attr_accessor :max_steps #: Numeric?
25
25
  attr_accessor :tools_config #: (Array[singleton(Riffer::Tool)] | Proc)?
26
26
  attr_reader :mcp_configs #: Array[Hash[Symbol, untyped]]
27
27
  attr_reader :tool_runtime #: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
@@ -35,7 +35,7 @@ class Riffer::Agent::Config
35
35
  # as a non-String, non-Proc value (or as an empty String).
36
36
  #
37
37
  #--
38
- #: (?identifier: String?, ?model: (String | Proc)?, ?instructions: (String | Proc)?, ?provider_options: Hash[Symbol, untyped], ?model_options: Hash[Symbol, untyped], ?structured_output: Riffer::Params?, ?max_steps: Numeric, ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?, ?mcp_configs: Array[Hash[Symbol, untyped]], ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc), ?skills_config: Riffer::Skills::Config?, ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]) -> void
38
+ #: (?identifier: String?, ?model: (String | Proc)?, ?instructions: (String | Proc)?, ?provider_options: Hash[Symbol, untyped], ?model_options: Hash[Symbol, untyped], ?structured_output: Riffer::Params?, ?max_steps: Numeric?, ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?, ?mcp_configs: Array[Hash[Symbol, untyped]], ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc), ?skills_config: Riffer::Skills::Config?, ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]) -> void
39
39
  def initialize(
40
40
  identifier: nil,
41
41
  model: nil,
@@ -19,6 +19,8 @@
19
19
  # context.token_usage # => nil
20
20
  #
21
21
  class Riffer::Agent::Context
22
+ # @rbs @data: Hash[Symbol, untyped]
23
+
22
24
  # Keys reserved for framework use. Passing any of these to the
23
25
  # constructor raises +Riffer::ArgumentError+.
24
26
  RESERVED_KEYS = [:skills, :token_usage].freeze #: Array[Symbol]
@@ -13,6 +13,8 @@
13
13
  # puts response.content
14
14
  # end
15
15
  class Riffer::Agent::Response
16
+ # @rbs @interrupted: bool
17
+
16
18
  # The response content.
17
19
  attr_reader :content #: String
18
20
 
@@ -77,7 +77,8 @@ module Riffer::Agent::Run
77
77
 
78
78
  break unless processed_response.has_tool_calls?
79
79
 
80
- throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if step >= agent.config.max_steps
80
+ max_steps = agent.config.max_steps
81
+ throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
81
82
 
82
83
  execute_tool_calls(agent, processed_response)
83
84
  end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ # Riffer::Agent::Serializer turns a resolved agent into a self-contained,
7
+ # provider-neutral data dict and back into a runnable agent. A pure module
8
+ # (sibling to Riffer::Agent::Run), reached most often through the
9
+ # +Riffer::Agent#to_h+ / +Riffer::Agent.from_h+ delegators.
10
+ #
11
+ # The dict carries only data — no Procs, no class references, no tool
12
+ # runtime. The same dict serves two rehydration targets:
13
+ #
14
+ # - <b>In-process</b> (a monolith persisting agent definitions): pass a
15
+ # +tool_resolver+ that looks tool descriptors up in a local registry and
16
+ # returns the real, body-bearing classes. They run on the default runtime.
17
+ # - <b>Distributed</b> (a receiver holding only the Riffer gem): the default
18
+ # resolver synthesizes body-less tool shells; inject a remote
19
+ # +Riffer::Tools::Runtime+ to forward each call back to the origin.
20
+ #
21
+ # dict = Riffer::Agent::Serializer.to_h(agent: agent)
22
+ # rebuilt = Riffer::Agent::Serializer.from_h(dict, context: {tenant: "acme"})
23
+ #
24
+ # == What does not transfer
25
+ #
26
+ # Guardrails and the skills subsystem (backend/adapter/catalog) are not
27
+ # serialized; a rebuilt agent enforces no guardrails and renders no skills
28
+ # catalog (the +skill_activate+ tool, if present, crosses as an ordinary
29
+ # tool). Secrets must not be placed in +provider_options+/+model_options+:
30
+ # both ride on the wire as plain data.
31
+ module Riffer::Agent::Serializer
32
+ extend self
33
+
34
+ # The wire format version. Bumped only on an incompatible change to the
35
+ # dict shape; +from_h+ refuses any other version. See +from_h+ for the
36
+ # dispatch seam that carries back-compat decoders.
37
+ SCHEMA_VERSION = 1 #: Integer
38
+
39
+ # Raised by +from_h+ when the dict's +schema_version+ is unsupported.
40
+ class VersionError < Riffer::ArgumentError; end
41
+
42
+ # The default +tool_resolver+: synthesizes a body-less tool shell from a
43
+ # descriptor. Its +#call+ raises — route shells through a remote runtime.
44
+ DEFAULT_TOOL_RESOLVER = ->(descriptor) { build_tool_shell(descriptor) } #: ^(Hash[Symbol, untyped]) -> singleton(Riffer::Tool)
45
+
46
+ # Snapshots a resolved agent into a self-contained wire dict.
47
+ #
48
+ # Reads the agent's resolved instance state — Proc-based settings have
49
+ # already been evaluated against the agent's own context, so the dict
50
+ # carries plain strings/data, never Procs. Tools are emitted as
51
+ # +{name, description, parameters_schema, timeout}+ descriptors (the
52
+ # resolved +agent.tools+, including MCP tools and +skill_activate+).
53
+ #
54
+ # [agent] a resolved Riffer::Agent instance.
55
+ #
56
+ #--
57
+ #: (agent: Riffer::Agent) -> Hash[Symbol, untyped]
58
+ def to_h(agent:)
59
+ config = agent.config
60
+ {
61
+ schema_version: SCHEMA_VERSION,
62
+ riffer_version: Riffer::VERSION,
63
+ identifier: config.identifier,
64
+ model: "#{agent.provider_name}/#{agent.model_name}",
65
+ instructions: agent.instruction_message&.content,
66
+ model_options: config.model_options,
67
+ provider_options: config.provider_options,
68
+ max_steps: encode_max_steps(config.max_steps),
69
+ structured_output: config.structured_output&.to_json_schema(strict: false),
70
+ tools: agent.tools.map { |tool_class| tool_descriptor(tool_class) }
71
+ }
72
+ end
73
+
74
+ # Reconstructs a runnable agent from a wire dict.
75
+ #
76
+ # [hash] a Symbol-keyed wire dict (parse JSON with +symbolize_names: true+).
77
+ # [context] the rebuilt agent's runtime context — the same value you'd pass
78
+ # to +Agent.new(context:)+. It is *not* used to re-resolve serialized
79
+ # config (the dict is already resolved); it is threaded into tool dispatch
80
+ # and read by tools/runtimes at call time (e.g. a remote runtime keying off
81
+ # <tt>context[:tenant]</tt>). Defaults to an empty context.
82
+ # [tool_resolver] maps a tool descriptor to a Riffer::Tool class. Defaults
83
+ # to DEFAULT_TOOL_RESOLVER (body-less shells). Pass a registry lookup to
84
+ # rebuild real, in-process tools.
85
+ # [tool_runtime] an optional Riffer::Tools::Runtime to inject (e.g. a
86
+ # remote runtime for shells). When omitted, the agent uses the configured
87
+ # default (+Riffer.config.tool_runtime+).
88
+ #
89
+ # Raises Riffer::Agent::Serializer::VersionError on an unsupported
90
+ # +schema_version+, and Riffer::ArgumentError on a malformed dict.
91
+ #
92
+ #--
93
+ #: (Hash[Symbol, untyped], ?context: Hash[Symbol, untyped]?, ?tool_resolver: ^(Hash[Symbol, untyped]) -> singleton(Riffer::Tool), ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?) -> Riffer::Agent
94
+ def from_h(hash, context: nil, tool_resolver: DEFAULT_TOOL_RESOLVER, tool_runtime: nil)
95
+ # Version -> decoder dispatch. Adding a +when 2+ arm (a backwards-compatible
96
+ # decoder) is how a future breaking change keeps older dicts readable.
97
+ case hash[:schema_version]
98
+ when SCHEMA_VERSION
99
+ decode_v1(hash, context: context, tool_resolver: tool_resolver, tool_runtime: tool_runtime)
100
+ else
101
+ raise VersionError, "Unsupported schema_version: #{hash[:schema_version].inspect} (this Riffer supports #{SCHEMA_VERSION})"
102
+ end
103
+ end
104
+
105
+ # Snapshots a resolved agent to a JSON string. Convenience over
106
+ # <tt>JSON.generate(to_h(agent:))</tt>.
107
+ #
108
+ #--
109
+ #: (agent: Riffer::Agent) -> String
110
+ def to_json(agent:)
111
+ JSON.generate(to_h(agent: agent))
112
+ end
113
+
114
+ # Reconstructs a runnable agent from a JSON string produced by +to_json+.
115
+ # Handles the JSON parse (with symbol keys) so callers don't have to. See
116
+ # +from_h+ for the arguments.
117
+ #
118
+ #--
119
+ #: (String, ?context: Hash[Symbol, untyped]?, ?tool_resolver: ^(Hash[Symbol, untyped]) -> singleton(Riffer::Tool), ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?) -> Riffer::Agent
120
+ def from_json(json, context: nil, tool_resolver: DEFAULT_TOOL_RESOLVER, tool_runtime: nil)
121
+ from_h(JSON.parse(json, symbolize_names: true), context: context, tool_resolver: tool_resolver, tool_runtime: tool_runtime)
122
+ end
123
+
124
+ private
125
+
126
+ #--
127
+ #: (Hash[Symbol, untyped], context: Hash[Symbol, untyped]?, tool_resolver: ^(Hash[Symbol, untyped]) -> singleton(Riffer::Tool), tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?) -> Riffer::Agent
128
+ def decode_v1(hash, context:, tool_resolver:, tool_runtime:)
129
+ tools = Array(hash[:tools]).map { |descriptor| tool_resolver.call(descriptor) }
130
+
131
+ config_args = {
132
+ identifier: hash[:identifier],
133
+ model: hash[:model],
134
+ instructions: hash[:instructions],
135
+ provider_options: hash[:provider_options] || {},
136
+ model_options: hash[:model_options] || {},
137
+ structured_output: decode_structured_output(hash[:structured_output]),
138
+ max_steps: decode_max_steps(hash),
139
+ tools_config: tools
140
+ } #: Hash[Symbol, untyped]
141
+ # tool_runtime= rejects nil, so only inject when supplied; otherwise the
142
+ # Config default (Riffer.config.tool_runtime) applies.
143
+ config_args[:tool_runtime] = tool_runtime if tool_runtime
144
+
145
+ Riffer::Agent.new(config: Riffer::Agent::Config.new(**config_args), context: context)
146
+ end
147
+
148
+ #--
149
+ #: (Hash[Symbol, untyped]?) -> Riffer::Params?
150
+ def decode_structured_output(schema)
151
+ return nil if schema.nil?
152
+ Riffer::Params.from_json_schema(schema)
153
+ end
154
+
155
+ # The DSL represents unlimited steps as +nil+, but the wire encodes it as
156
+ # +-1+ so the dict stays portable across transports where JSON +null+ is
157
+ # awkward (e.g. proto3, which can't tell null from an absent field). The
158
+ # magic value lives only on the wire — +encode_max_steps+/+decode_max_steps+
159
+ # translate at the boundary so neither the DSL nor consumers see it.
160
+ #--
161
+ #: (Numeric?) -> Numeric
162
+ def encode_max_steps(value)
163
+ value.nil? ? -1 : value
164
+ end
165
+
166
+ # Reverses +encode_max_steps+: +-1+ (or a literal +null+) means unlimited.
167
+ # An absent key falls back to the default — a partial dict must not silently
168
+ # become an unbounded loop.
169
+ #--
170
+ #: (Hash[Symbol, untyped]) -> Numeric?
171
+ def decode_max_steps(hash)
172
+ return Riffer::Agent::Config::DEFAULT_MAX_STEPS unless hash.key?(:max_steps)
173
+ (hash[:max_steps] == -1) ? nil : hash[:max_steps]
174
+ end
175
+
176
+ #--
177
+ #: (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]
178
+ def tool_descriptor(tool_class)
179
+ tool_class.to_tool_schema(strict: false).merge(timeout: tool_class.timeout)
180
+ end
181
+
182
+ # Builds an anonymous, body-less Riffer::Tool subclass that advertises the
183
+ # descriptor's schema to the LLM. Its +#call+ raises — a shell only has
184
+ # identity, not behavior; route its calls through a remote runtime.
185
+ #
186
+ # Returns +untyped+: steep can't see that +Class.new(Riffer::Tool)+ is a
187
+ # +singleton(Riffer::Tool)+ (cf. Riffer::Mcp::ToolFactory#build_tool_class).
188
+ #--
189
+ #: (Hash[Symbol, untyped]) -> untyped
190
+ def build_tool_shell(descriptor)
191
+ tool_name = descriptor[:name]
192
+ tool_description = descriptor[:description]
193
+ schema = descriptor[:parameters_schema]
194
+ tool_timeout = descriptor[:timeout]
195
+
196
+ # An anonymous Riffer::Tool subclass is the idiom for synthesizing a tool
197
+ # from data — the tool DSL is class-level, so there is no value-level
198
+ # builder to type against. Same approach as Riffer::Mcp::ToolFactory;
199
+ # steep can't type the dynamic class body, hence the ignore block.
200
+ Class.new(Riffer::Tool) do
201
+ # steep:ignore:start
202
+ identifier tool_name
203
+ description tool_description
204
+ timeout tool_timeout if tool_timeout
205
+ define_singleton_method(:parameters_schema) { |strict: false| schema }
206
+
207
+ define_method(:call) do |context:, **kwargs|
208
+ raise Riffer::Error,
209
+ "#{self.class.name || "wire tool shell"} '#{self.class.identifier}' has no body; " \
210
+ "route its calls through a remote Riffer::Tools::Runtime (see Riffer::Agent::Serializer)"
211
+ end
212
+ # steep:ignore:end
213
+ end
214
+ end
215
+ end
@@ -18,6 +18,8 @@
18
18
  class Riffer::Agent::Session
19
19
  include Enumerable #[Riffer::Messages::Base]
20
20
 
21
+ # @rbs @callbacks: Array[^(Riffer::Messages::Base) -> void]
22
+
21
23
  # The message history.
22
24
  attr_reader :messages #: Array[Riffer::Messages::Base]
23
25