smith-agents 0.4.2 → 0.4.3

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -3
  3. data/README.md +45 -2
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +41 -1
  6. data/lib/smith/agent/registry/introspection.rb +27 -0
  7. data/lib/smith/agent/registry.rb +22 -17
  8. data/lib/smith/agent/registry_binding.rb +43 -0
  9. data/lib/smith/agent.rb +3 -4
  10. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  11. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  12. data/lib/smith/version.rb +1 -1
  13. data/lib/smith/workflow/agent_result.rb +26 -0
  14. data/lib/smith/workflow/branch_env.rb +24 -0
  15. data/lib/smith/workflow/budget_integration.rb +2 -0
  16. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  17. data/lib/smith/workflow/deterministic_step.rb +32 -4
  18. data/lib/smith/workflow/durability.rb +38 -12
  19. data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
  20. data/lib/smith/workflow/event_integration.rb +3 -3
  21. data/lib/smith/workflow/execution.rb +2 -0
  22. data/lib/smith/workflow/fanout_execution.rb +2 -0
  23. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  24. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  25. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  26. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  27. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  28. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  30. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  31. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  33. data/lib/smith/workflow/graph/targets.rb +5 -0
  34. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  35. data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
  36. data/lib/smith/workflow/graph.rb +16 -1
  37. data/lib/smith/workflow/graph_dsl.rb +4 -0
  38. data/lib/smith/workflow/guardrail_integration.rb +20 -3
  39. data/lib/smith/workflow/nested_execution.rb +5 -4
  40. data/lib/smith/workflow/optimization_state.rb +13 -0
  41. data/lib/smith/workflow/orchestration_state.rb +13 -0
  42. data/lib/smith/workflow/orchestrator_worker.rb +3 -15
  43. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  44. data/lib/smith/workflow/parallel.rb +6 -15
  45. data/lib/smith/workflow/parallel_execution.rb +2 -0
  46. data/lib/smith/workflow/persistence.rb +37 -6
  47. data/lib/smith/workflow/router.rb +15 -4
  48. data/lib/smith/workflow/run_result.rb +54 -0
  49. data/lib/smith/workflow/transition.rb +90 -4
  50. data/lib/smith/workflow/usage_entry.rb +30 -0
  51. data/lib/smith/workflow/worker_execution.rb +13 -0
  52. data/lib/smith/workflow.rb +40 -136
  53. data/lib/smith.rb +9 -0
  54. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c5a7f554819eb342aa2284fb010eba8126f22fba1348683a36fbc4fa9d5383f
4
- data.tar.gz: b72e10a5415340c5b337751c0cc92f2f59ed0ddb62850e03746df217be374721
3
+ metadata.gz: 8b8a1ad3fc2cb40ef0287063f81e0bc01a6bca349b1625c4173daef763785202
4
+ data.tar.gz: b870358edea9686d15e164bb3d44e00edf8e50da8cb2d68b8a6a666bbc5d0f79
5
5
  SHA512:
6
- metadata.gz: 75d149a61196f2c0cd22748ff6d5a6c4530bc9d91c61b163c027e44d555b9fd0f1ca5ef0a283c6025d6906b6d984923265fba05190fa3da37a60cfe864e5f6f8
7
- data.tar.gz: 906bc56189df0f84ad62cb643ed8141a05780a328bf9a3770db55c575d71db86b94ad39e91517ba0e2d76f05942f84358dc234b2e685ad0701dba059bed65d36
6
+ metadata.gz: bd9dd710fb5008efc7896e950b52621d3c80dab9be902b323efdc76c3d4d12e4b1c066b2219e5e36c56edea5652add7406ecb1d740db79b320035eee9047cee6
7
+ data.tar.gz: 4d5e6b412d39269276096b697a399ad476dd1d4bc370792d8acfdd8f49041b567da2120e56a0fc1e090cbf4c351c9849201c8b0030fd5518f6c69495b175f534
data/CHANGELOG.md CHANGED
@@ -6,7 +6,59 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- No unreleased changes.
9
+ ## [0.4.3] - 2026-07-05
10
+
11
+ ### Documentation
12
+
13
+ - Clarify Smith's repair and wait-style loop boundaries: `retry_on` and
14
+ `optimize` are executable today, deterministic repair and guarded re-entry
15
+ are not native first-class contracts yet, and durable polling/wait semantics
16
+ remain host-owned unless an explicit wait contract exists.
17
+
18
+ ### Added
19
+
20
+ - Static graph-inspection contracts for `optimize` and `orchestrate`
21
+ transitions, including bounded loop/delegation settings, schema labels, output
22
+ contracts, exit policies, dispatch semantics, and transition-level resume
23
+ guarantees.
24
+ - `Workflow.runtime_readiness`, a static diagnostic report that separates graph
25
+ topology validity from runtime binding readiness without executing agents,
26
+ tools, providers, jobs, or persistence.
27
+ - Runtime-readiness diagnostics for unresolved, invalid, lazy/uninspectable,
28
+ model-less, and model-required agent bindings across execute, route,
29
+ optimize, orchestrate, nested, and fan-out workflow shapes.
30
+ - Runtime-readiness metrics now expose direct counts and transitive counts folded
31
+ in from nested workflows.
32
+ - `Smith::Agent::Registry.binding_for` and `.bindings` expose non-resolving
33
+ registry inspection for diagnostics and host cleanup.
34
+ - Richer fan-out transition snapshot metadata: branch count, join state,
35
+ ordered branch list, output contract, resume contract, and per-branch result
36
+ contracts for named branch-result output.
37
+ - Direct doctor coverage for registered agent model-profile checks, including
38
+ static primary and static fallback models, making safe-default model shaping
39
+ explicit before hosts rely on runtime behavior.
40
+
41
+ ### Changed
42
+
43
+ - `smith doctor --profile rails_persistence` now reports the full optional
44
+ persistence capability surface (`store_versioned`, `record_heartbeat`, and
45
+ `last_heartbeat`) instead of checking optimistic locking only.
46
+ - Workflow runtime value objects now live in dedicated files while preserving
47
+ the existing public constants (`Smith::Workflow::RunResult`,
48
+ `AgentResult`, `UsageEntry`, `BranchEnv`, and internal execution helpers).
49
+ - Release documentation now reflects the current heartbeat optional-capability
50
+ contract and the RubyLLM integration boundary.
51
+
52
+ ### Test coverage
53
+
54
+ - Default suite: 926 examples, 0 failures.
55
+ - Practical gem-level execution probe covering 30 varied workflows across
56
+ strict/lax idempotency, same-agent parallel branches, heterogeneous fan-out,
57
+ retry metadata, optimizer contracts, and orchestrator-worker flows.
58
+ - Smith Studio host verification against the local Smith checkout: 186 runtime
59
+ tests and a 30-scenario generated-class lifecycle proof gate.
60
+ - Built `pkg/smith-agents-0.4.3.gem` and smoke-tested `require "smith"` from
61
+ the unpacked package.
10
62
 
11
63
  ## [0.4.2] - 2026-07-02
12
64
 
@@ -128,7 +180,7 @@ This release tracks two thematic refactors that together harden the agent-invoca
128
180
  - `Smith::Models` registry (Dry::Container-backed) for application-side `Smith::Models::Profile` overrides; mirrors the `Smith::Agent::Registry` stale-reload-binding pattern for Rails autoreload safety.
129
181
  - `Smith::Models::Profile` immutable capability record (`Data.define`) covering thinking_shape, accepts_temperature, tools_with_thinking_native, tools_with_thinking_route, and a derived `endpoint_mode`.
130
182
  - `Smith::Models::Inference` library-shipped pattern rules describing each provider family's payload shape (Anthropic Opus 4.7+ adaptive, Anthropic 4.0-4.6 budget_tokens, OpenAI gpt-5 family reasoning_effort + responses route, OpenAI gpt-4.x, Gemini 2.5+ budget_tokens, etc.). Smith ships zero hardcoded model_ids; new model releases that match an existing pattern work without library changes.
131
- - `Smith::Models::Normalizer.apply!(chat, profile:)` per-attempt request shaper. Translates Anthropic Opus 4.7+ thinking to the adaptive payload shape (`@params[:thinking] = { type: "adaptive" }` + `output_config[:effort]`), nulls temperature where the resolved profile forbids it, routes `(gpt-5 + tools + thinking)` via `openai_api_mode: :responses` when supported, drops incompatible tools otherwise. Hooks at `Smith::Agent.chat()` so direct callers (e.g., InvokeCleaner-style usage outside the workflow lifecycle) are normalized too.
183
+ - `Smith::Models::Normalizer.apply!(chat, profile:)` per-attempt request shaper. Translates Anthropic Opus 4.7+ thinking to the adaptive payload shape (`@params[:thinking] = { type: "adaptive" }` + `output_config[:effort]`), nulls temperature where the resolved profile forbids it, routes `(gpt-5 + tools + thinking)` via `openai_api_mode: :responses` when supported, drops incompatible tools otherwise. Hooks at `Smith::Agent.chat()` so direct callers outside the workflow lifecycle are normalized too.
132
184
  - `Smith::Agent::RESERVED_INPUT_NAMES = %i[model_id provider endpoint_mode]` auto-injected into `runtime_context` per attempt from the resolved profile. The `Smith::Agent.inputs` getter returns reserved ∪ user (frozen, deduplicated); the setter raises `Smith::AgentError` if a user-declared name collides with a reserved name.
133
185
  - `Smith::Tool.compatible_with(...)` DSL for declaring per-(provider, endpoint) tool compatibility. `Smith::Tool.inherited` dups the spec so subclasses inherit the parent's compatibility metadata.
134
186
  - `Smith::Tools::Think` declares `compatible_with :anthropic, :gemini, openai: :responses`. Drops gracefully on OpenAI chat-completions when `openai_api_mode = :off`, runs via `/v1/responses` when `:auto`.
@@ -188,4 +240,4 @@ This release tracks two thematic refactors that together harden the agent-invoca
188
240
 
189
241
  ## [0.1.0] - Initial public-track release
190
242
 
191
- Initial pre-release tagged for the Hadithi consumer's local-path dependency. No formal changelog prior to the Phase A/B refactor.
243
+ Initial pre-release. No formal changelog prior to the Phase A/B refactor.
data/README.md CHANGED
@@ -17,7 +17,7 @@ environment before calling the slice complete.
17
17
 
18
18
  ```ruby
19
19
  # Gemfile
20
- gem "smith-agents", "~> 0.4.2", require: "smith"
20
+ gem "smith-agents", "~> 0.4.3", require: "smith"
21
21
  ```
22
22
 
23
23
  ```bash
@@ -103,6 +103,21 @@ end
103
103
 
104
104
  The full pattern guide with working examples for each lives in [`docs/PATTERNS.md`](docs/PATTERNS.md).
105
105
 
106
+ ### Repair And Wait Boundaries
107
+
108
+ Smith only owns repair and wait-style loop behavior when the bounds and stop
109
+ conditions are explicit and enforceable inside the workflow step. Durable
110
+ timers, queue delivery, and wake-up policy remain host-owned.
111
+ For bounded dynamic delegation, use the separate Orchestrator-Worker pattern.
112
+
113
+ | Contract | Status | Smith mapping |
114
+ |---|---|---|
115
+ | Retry loop | Executable | `retry_on`, bounded to one transition. |
116
+ | Evaluator-Optimizer | Executable | `optimize`, bounded by `max_rounds` plus structured evaluator output. |
117
+ | Deterministic repair | Not first-class yet | Can be handwritten with `compute` / `run` only when the workflow author owns the exact guard, repair, revalidation, and exit policy. Deterministic steps may declare inspectable `routes: [...]`, but that is not a native repair-loop contract. |
118
+ | Guarded state re-entry | Not first-class yet | `compute` / `run` can declare and route to named transitions with `routes: [...]`, but Smith does not yet own persisted entry counts, mutation policy, or safe re-entry contracts. |
119
+ | Polling / wait | Host-owned | Use the host app's queue/timer plus Smith persistence helpers. Smith must not model durable polling with busy-waits or sleep loops. |
120
+
106
121
  ## Workflow Graph Inspection
107
122
 
108
123
  Smith can inspect a workflow's declared graph without running agents or advancing state. This is useful for host apps that want to render, lint, or cache a workflow shape before execution.
@@ -118,6 +133,31 @@ report.metrics # => state, transition, reachability, and terminal-state co
118
133
 
119
134
  Graph inspection is static and diagnostic-only. Runtime execution, persistence, progress projection, retries, and recovery remain host-owned concerns.
120
135
 
136
+ Smith also exposes a static runtime-readiness report for checks that require
137
+ declared runtime bindings but still do not execute the workflow:
138
+
139
+ ```ruby
140
+ readiness = ReplyWorkflow.runtime_readiness
141
+
142
+ readiness.ready? # => true when no readiness/topology errors exist
143
+ readiness.status # => :ready, :warning, or :not_ready
144
+ readiness.diagnostics # => topology diagnostics + runtime binding diagnostics
145
+ ```
146
+
147
+ Runtime readiness checks graph topology, registered agent bindings, model
148
+ requirements for structured runtime roles, lazy/uninspectable bindings, invalid
149
+ non-agent bindings, nested workflow readiness, and fan-out branch binding counts.
150
+ It does not call providers, resolve lazy container blocks, run tools, enqueue
151
+ jobs, or verify host-owned durability.
152
+
153
+ Readiness metrics include both direct graph counts and transitive counts folded
154
+ in from nested workflows.
155
+
156
+ Transition snapshots include runtime contracts for complex primitives where
157
+ Smith owns executable semantics: heterogeneous fan-out, evaluator-optimizer, and
158
+ orchestrator-worker transitions expose bounded settings, output shapes, and
159
+ transition-level resume behavior for host renderers and compilers.
160
+
121
161
  ## Configuration
122
162
 
123
163
  ```ruby
@@ -162,7 +202,10 @@ result = ReplyWorkflow.run_persisted!(
162
202
  )
163
203
  ```
164
204
 
165
- Built-in adapters (all support TTL where the backend allows; `Redis`, `ActiveRecord`, `Memory` also support optimistic locking via `store_versioned`):
205
+ Built-in adapters (all support TTL where the backend allows; `Redis`,
206
+ `ActiveRecord`, and `Memory` support optimistic locking via `store_versioned`;
207
+ `Redis` and `Memory` also support heartbeat probes via `record_heartbeat` /
208
+ `last_heartbeat`):
166
209
 
167
210
  - `:memory` — in-process Hash, intended for tests and `test_mode = true`
168
211
  - `:redis` — Redis client; uses WATCH/MULTI/EXEC for CAS
data/UPSTREAM_PROPOSAL.md CHANGED
@@ -139,3 +139,22 @@ What stays Smith-owned (orchestration concerns, not provider-API concerns):
139
139
 
140
140
  - RubyLLM PR #770 (OpenAI `/v1/responses` support) is the related upstream track. Smith's vendored `Smith::Providers::OpenAI::Responses` retires when #770 merges.
141
141
  - This proposal (capability profiles + before_complete) is a separate, additive RubyLLM RFC. Once accepted, Smith files a migration PR to consume it.
142
+
143
+ ## Current RubyLLM Docs Check
144
+
145
+ As of the release-prep audit, RubyLLM's official documentation describes a model
146
+ registry with model capability and pricing data, Rails DB-backed model registry
147
+ support, provider overrides, instrumentation, and concurrent tool execution.
148
+ Smith should continue treating RubyLLM as the source of truth for provider
149
+ communication and model inventory while keeping Smith-specific workflow
150
+ semantics, request shaping, and tool-compatibility policy explicit.
151
+
152
+ Release implication: before deleting Smith's model normalizer, Responses
153
+ adapter, or model-profile inference layer, verify that RubyLLM exposes the
154
+ specific public hooks Smith needs for:
155
+
156
+ - clearing incompatible temperature/thinking settings without private ivar
157
+ mutation
158
+ - request-shaping hooks before provider payload rendering
159
+ - endpoint selection for tools plus thinking/reasoning combinations
160
+ - model capability fields that distinguish Smith's workflow-relevant cases
data/docs/PATTERNS.md CHANGED
@@ -276,6 +276,8 @@ What you get:
276
276
  - one shared prepared input for the transition
277
277
  - one shared transition result, so downstream joins remain explicit in the workflow
278
278
  - branch failures discard partial output and route through normal failure handling
279
+ - graph inspection exposes the join state, branch count, ordered branch map,
280
+ output contract, resume contract, and per-branch named result contracts
279
281
 
280
282
  Use same-agent `parallel: true` for repeated homogeneous work. Use `fan_out` when branches are different agents with different responsibilities.
281
283
 
@@ -374,6 +376,40 @@ Why this matters:
374
376
  - acceptance criteria are structured
375
377
  - exhaustion, malformed evaluator output, and convergence without acceptance fail normally
376
378
  - costs and token usage from the full loop roll into the workflow totals
379
+ - graph inspection exposes the generator/evaluator bindings, max rounds, schema
380
+ label, evaluator context, exit modes, required/conditional evaluator output
381
+ fields, and transition-level resume contract
382
+
383
+ ## Repair And Wait Boundaries
384
+
385
+ Smith's workflow layer is intentionally bounded. A repair or wait-style loop is
386
+ executable in Smith only when Smith can validate the contract and enforce
387
+ deterministic stopping rules. This section does not restate every bounded
388
+ workflow helper; Orchestrator-Worker is a separate bounded delegation pattern.
389
+ Today that means:
390
+
391
+ | Loop kind | Executable? | Smith primitive | Boundary |
392
+ |---|---|---|---|
393
+ | Retry | Yes | `retry_on` | Local transition retry only. Durable scheduling and idempotency stay with the host. |
394
+ | Evaluator-Optimizer | Yes | `optimize` | Bounded refinement inside one transition using structured evaluator output. |
395
+ | Deterministic repair | Not native | Handwritten `compute` / `run` when exact semantics are owned by the workflow author. Deterministic route targets can be declared with `routes: [...]`. | No first-class repair contract or persisted repair counts yet. |
396
+ | Guarded state re-entry | Not native | Handwritten `compute` / `run` may declare and route to named transitions with `routes: [...]`. | No Smith-owned entry-count ledger, re-entry guard contract, or mutation policy yet. |
397
+ | Polling / wait | No | Host queue/timer plus Smith persistence helpers. | Smith must not model durable polling with sleeps, busy-waits, or `max_transitions` cycling. |
398
+
399
+ Do not hide a durable wait or unbounded repair policy inside `compute` / `run`.
400
+ Those primitives are synchronous deterministic steps inside the current workflow
401
+ runner. They are appropriate for local verification, normalization, failure
402
+ classification, explicit routing, and bounded retry-adjacent checks; they are
403
+ not a durable scheduler.
404
+
405
+ If a host or compiler wants deterministic repair, polling/wait, or guarded
406
+ state re-entry as reusable graph contracts, the contract must be added explicitly
407
+ before code generation claims executability. Deterministic `routes: [...]`
408
+ annotations make handwritten route targets inspectable, but they do not by
409
+ themselves provide bounded repair counts, mutation policy, or durable wait
410
+ semantics. At minimum that future contract needs bounded attempts, persisted
411
+ state keys where appropriate, deterministic exit behavior, graph-inspection
412
+ metadata, and practical execution coverage.
377
413
 
378
414
  ## Example 9: Orchestrator-Worker
379
415
 
@@ -454,6 +490,9 @@ Why this is valuable:
454
490
  - tasks and outputs are structured
455
491
  - worker fan-out is controlled
456
492
  - exhaustion and malformed orchestrator output fail as first-class workflow failures
493
+ - graph inspection exposes orchestrator/worker bindings, bounds, schema labels,
494
+ decision/output contracts, serial worker-dispatch semantics, and
495
+ transition-level resume behavior
457
496
 
458
497
  Notes:
459
498
 
@@ -520,7 +559,7 @@ The yielded step object exposes a narrow, read-heavy surface:
520
559
 
521
560
  ### Behavior
522
561
 
523
- - **Routing**: `step.route_to` overrides `on_success`. If neither is set, normal state-based resolution applies. Named transitions that do not exist fail loudly with `WorkflowError`.
562
+ - **Routing**: `step.route_to` overrides `on_success`. If neither is set, normal state-based resolution applies. Named transitions that do not exist fail loudly with `WorkflowError`. When a deterministic transition declares `compute(routes: [...])` or `run(routes: [...])`, `step.route_to` must target one of those names; graph inspection validates the annotated targets before runtime.
524
563
  - **Failure**: `step.fail!` raises `Smith::DeterministicStepFailure` (extends `WorkflowError`) with `retryable`, `kind`, and `details` metadata. Routes through `on_failure` like any other step failure.
525
564
  - **Outcome**: `step.write_outcome(kind:, payload:)` stores a workflow-owned terminal payload without smuggling it through context. The payload is persisted with the workflow and surfaced on `RunResult.outcome`, `RunResult.outcome_kind`, and `RunResult.outcome_payload`.
526
565
  - **Context reads**: `step.context` returns an isolated snapshot of the workflow context at step start. Mutating that snapshot does not mutate workflow state. `step.read_context(key)` returns a merged view — pending `write_context` values override the snapshot. Use `read_context` when you need read-after-write coherence within the same step.
@@ -529,3 +568,4 @@ The yielded step object exposes a narrow, read-heavy surface:
529
568
  - **Persistence**: Context writes and written outcomes survive `to_state`/`from_state`. The block itself (a Proc) lives on the class-level Transition and is never serialized.
530
569
  - **Trace**: Emits `:deterministic_step` traces for start, success/routed, and failure. When a step writes an outcome, the trace includes `outcome_kind`.
531
570
  - **Mutual exclusivity**: `compute` and `run` cannot be combined with `execute`, `route`, `workflow`, `optimize`, or `orchestrate`. A transition declares exactly one primary execution body.
571
+ - **No hidden scheduler**: `compute` and `run` execute synchronously inside the current runner. Use them for bounded deterministic logic, not for durable polling or wake-up loops.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Registry
6
+ module Introspection
7
+ def binding_for(name)
8
+ registry_monitor.synchronize do
9
+ key = normalize_key(name)
10
+ item = _container[key]
11
+ item ? Smith::Agent::RegistryBinding.new(key: key, item: item).to_h : nil
12
+ end
13
+ end
14
+
15
+ def bindings
16
+ registry_monitor.synchronize do
17
+ _container.each_with_object({}) do |(key, item), binding_map|
18
+ binding_map[key] = Smith::Agent::RegistryBinding.new(key: key, item: item).to_h
19
+ end.freeze
20
+ end
21
+ end
22
+ end
23
+
24
+ extend Introspection
25
+ end
26
+ end
27
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "dry-container"
4
4
  require "monitor"
5
+ require_relative "registry_binding"
5
6
 
6
7
  module Smith
7
8
  class Agent
@@ -14,14 +15,10 @@ module Smith
14
15
 
15
16
  def self.find(name)
16
17
  registry_monitor.synchronize do
17
- key = normalize_key(name)
18
- key?(key) ? resolve(key) : nil
18
+ registered_binding(normalize_key(name))
19
19
  end
20
20
  end
21
21
 
22
- # Override Dry::Container::Mixin#register to route agent classes
23
- # through ensure_registered while preserving full generic container
24
- # semantics (block, options) for non-agent registrations.
25
22
  def self.register(key, contents = nil, options = {}, &block)
26
23
  if block_given? || !(contents.is_a?(Class) && contents <= Smith::Agent)
27
24
  registry_monitor.synchronize { super(key, contents, options, &block) }
@@ -47,7 +44,7 @@ module Smith
47
44
  key = normalize_key(name)
48
45
 
49
46
  registry_monitor.synchronize do
50
- existing = key?(key) ? resolve(key) : nil
47
+ existing = registered_binding(key)
51
48
 
52
49
  if existing.nil?
53
50
  register_unchecked!(key, klass)
@@ -71,22 +68,15 @@ module Smith
71
68
  def self.fetch!(name, workflow_class: nil, transition_name: nil, role: :agent)
72
69
  registry_monitor.synchronize do
73
70
  key = normalize_key(name)
74
- return resolve(key) if key?(key)
75
-
76
- details = []
77
- details << "workflow #{workflow_class}" if workflow_class
78
- details << "transition :#{transition_name}" if transition_name
79
- suffix = details.empty? ? "" : " for #{details.join(', ')}"
71
+ binding = registered_binding(key)
72
+ return binding if binding
80
73
 
81
- raise Smith::WorkflowError, "unresolved #{role} :#{key}#{suffix}"
74
+ raise Smith::WorkflowError, "unresolved #{role} :#{key}#{fetch_suffix(workflow_class, transition_name)}"
82
75
  end
83
76
  end
84
77
 
85
- # Re-entrant lock (Monitor, not Mutex) so block-backed resolve
86
- # inside find/fetch! can safely re-enter the registry without
87
- # deadlocking on the same thread.
88
78
  def self.registry_monitor
89
- @_registry_monitor ||= Monitor.new
79
+ @registry_monitor ||= Monitor.new
90
80
  end
91
81
 
92
82
  def self.validate_agent_class!(klass)
@@ -115,6 +105,19 @@ module Smith
115
105
  end
116
106
  private_class_method :register_unchecked!
117
107
 
108
+ def self.registered_binding(key)
109
+ key?(key) ? resolve(key) : nil
110
+ end
111
+ private_class_method :registered_binding
112
+
113
+ def self.fetch_suffix(workflow_class, transition_name)
114
+ details = []
115
+ details << "workflow #{workflow_class}" if workflow_class
116
+ details << "transition :#{transition_name}" if transition_name
117
+ details.empty? ? "" : " for #{details.join(", ")}"
118
+ end
119
+ private_class_method :fetch_suffix
120
+
118
121
  def self.stale_reload_binding?(existing, klass)
119
122
  existing_name = existing.respond_to?(:name) ? existing.name : nil
120
123
  klass_name = klass.name
@@ -126,3 +129,5 @@ module Smith
126
129
  end
127
130
  end
128
131
  end
132
+
133
+ require_relative "registry/introspection"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Agent
7
+ class RegistryBinding
8
+ extend Dry::Initializer
9
+
10
+ option :key
11
+ option :item
12
+
13
+ def to_h
14
+ {
15
+ key: key,
16
+ agent_class: agent_class,
17
+ call: call?,
18
+ raw_binding: raw_binding
19
+ }.freeze
20
+ end
21
+
22
+ private
23
+
24
+ def agent_class
25
+ return unless call? == false
26
+
27
+ raw_binding if raw_binding.is_a?(Class) && raw_binding <= Smith::Agent
28
+ end
29
+
30
+ def call?
31
+ options.fetch(:call, true)
32
+ end
33
+
34
+ def raw_binding
35
+ item.instance_variable_get(:@item)
36
+ end
37
+
38
+ def options
39
+ item.instance_variable_get(:@options) || {}
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/smith/agent.rb CHANGED
@@ -142,10 +142,9 @@ module Smith
142
142
 
143
143
  # Closes the `inputs` contract at the chat() boundary AND runs the
144
144
  # Smith::Models::Normalizer. Hook lives here (not in
145
- # Lifecycle#attempt_model) so direct callers like hadithi-xl's
146
- # InvokeCleaner.chat (which constructs a chat outside the workflow
147
- # lifecycle) are normalized too. Without this placement, Cleaner's
148
- # Opus 4.7 adaptive thinking translation would only fire for
145
+ # Lifecycle#attempt_model) so direct callers that construct a chat
146
+ # outside the workflow lifecycle are normalized too. Without this
147
+ # placement, model-specific request translation would only fire for
149
148
  # workflow-driven calls.
150
149
  #
151
150
  # Single profile lookup: resolved once via Models.find_or_infer and
@@ -36,29 +36,40 @@ module Smith
36
36
  end
37
37
  end
38
38
 
39
- # Walk Smith::Agent::Registry. For each agent, extract the model
40
- # id from chat_kwargs (static `model "..."` form). Block-form
41
- # `model do |ctx| ... end` agents are skipped because their
42
- # model is resolved per-attempt and can't be enumerated at boot.
39
+ # Walk Smith::Agent::Registry. For each agent, extract every static
40
+ # model id Smith can know at boot: the primary `model "..."` value and
41
+ # any static fallback models. Block-form primary models are skipped
42
+ # because they resolve per-attempt, but their static fallbacks still
43
+ # need coverage checks.
43
44
  # Check whether find_or_infer returns a custom (non-default)
44
45
  # Profile — meaning either an explicit override or an inference
45
46
  # rule matched.
46
47
  def uncovered_models
47
48
  return [] unless defined?(Smith::Agent::Registry)
48
49
 
49
- model_ids = []
50
- Smith::Agent::Registry.each do |_key, agent|
51
- next unless agent.is_a?(Class)
52
- next unless agent.respond_to?(:chat_kwargs)
50
+ static_model_ids.uniq.reject { |model_id| covered_model?(model_id) }
51
+ end
53
52
 
54
- id = agent.chat_kwargs[:model]
55
- model_ids << id if id
53
+ def static_model_ids
54
+ Smith::Agent::Registry.each.with_object([]) do |(_key, agent), ids|
55
+ ids.concat(static_model_ids_for(agent)) if inspectable_agent?(agent)
56
56
  end
57
+ end
57
58
 
58
- model_ids.uniq.reject do |model_id|
59
- Smith::Models.find(model_id) ||
60
- (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
61
- end
59
+ def inspectable_agent?(agent)
60
+ agent.is_a?(Class) && agent.respond_to?(:chat_kwargs)
61
+ end
62
+
63
+ def static_model_ids_for(agent)
64
+ [
65
+ agent.chat_kwargs[:model],
66
+ *(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil)
67
+ ].compact
68
+ end
69
+
70
+ def covered_model?(model_id)
71
+ Smith::Models.find(model_id) ||
72
+ (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
62
73
  end
63
74
  end
64
75
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../persistence_adapters"
4
+
3
5
  module Smith
4
6
  module Doctor
5
7
  module Checks
@@ -12,41 +14,16 @@ module Smith
12
14
  module PersistenceCapabilities
13
15
  module_function
14
16
 
15
- OPTIONAL_CAPABILITIES = %i[store_versioned].freeze
17
+ OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
16
18
 
17
19
  def run(report)
18
20
  adapter = resolve_adapter
19
- if adapter.nil?
20
- report.add(
21
- name: "persistence.capabilities",
22
- status: :warn,
23
- message: "No persistence adapter configured",
24
- detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
25
- "Hosts using durable workflows must set persistence_adapter."
26
- )
27
- return
28
- end
21
+ return report_missing_adapter(report) if adapter.nil?
29
22
 
30
23
  missing = OPTIONAL_CAPABILITIES.reject { |cap| Smith::PersistenceAdapters.supports?(adapter, cap) }
24
+ return report_supported_capabilities(report, adapter) if missing.empty?
31
25
 
32
- if missing.empty?
33
- report.add(
34
- name: "persistence.capabilities",
35
- status: :pass,
36
- message: "#{adapter.class.name} supports all optional persistence capabilities",
37
- detail: "Supported: #{OPTIONAL_CAPABILITIES.join(', ')}"
38
- )
39
- else
40
- report.add(
41
- name: "persistence.capabilities",
42
- status: :warn,
43
- message: "#{adapter.class.name} missing optional capabilities: #{missing.join(', ')}",
44
- detail: "Workflows using these capabilities fall back to non-versioned writes " \
45
- "with a one-time warning per adapter class. Switch to RedisStore, " \
46
- "ActiveRecordStore (with lock_version column), or the Memory adapter " \
47
- "for full coverage."
48
- )
49
- end
26
+ report_missing_capabilities(report, adapter, missing)
50
27
  end
51
28
 
52
29
  def resolve_adapter
@@ -54,6 +31,37 @@ module Smith
54
31
  rescue StandardError
55
32
  nil
56
33
  end
34
+
35
+ def report_missing_adapter(report)
36
+ report.add(
37
+ name: "persistence.capabilities",
38
+ status: :warn,
39
+ message: "No persistence adapter configured",
40
+ detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
41
+ "Hosts using durable workflows must set persistence_adapter."
42
+ )
43
+ end
44
+
45
+ def report_supported_capabilities(report, adapter)
46
+ report.add(
47
+ name: "persistence.capabilities",
48
+ status: :pass,
49
+ message: "#{adapter.class.name} supports all optional persistence capabilities",
50
+ detail: "Supported: #{OPTIONAL_CAPABILITIES.join(", ")}"
51
+ )
52
+ end
53
+
54
+ def report_missing_capabilities(report, adapter, missing)
55
+ report.add(
56
+ name: "persistence.capabilities",
57
+ status: :warn,
58
+ message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
59
+ detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
60
+ "is missing, and payload updated_at parsing when heartbeat methods are missing. " \
61
+ "Use RedisStore or Memory for full versioning and heartbeat coverage; " \
62
+ "ActiveRecordStore currently covers optimistic locking when lock_version is present."
63
+ )
64
+ end
57
65
  end
58
66
  end
59
67
  end
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.3"
5
5
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ AgentResult = Struct.new(
7
+ :content, :input_tokens, :output_tokens, :cost, :model_used,
8
+ keyword_init: true
9
+ ) do
10
+ def self.from_response(response, content, model_used: nil)
11
+ new(
12
+ content: content,
13
+ input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
14
+ output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
15
+ cost: nil,
16
+ model_used: model_used
17
+ )
18
+ end
19
+
20
+ def usage_known?
21
+ !input_tokens.nil? && !output_tokens.nil?
22
+ end
23
+ end
24
+ # rubocop:enable Style/RedundantStructKeywordInit
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ BranchEnv = Struct.new(
7
+ :prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
8
+ keyword_init: true
9
+ ) do
10
+ def setup_thread
11
+ Smith::Tool.current_guardrails = guardrail_sources
12
+ Smith::Tool.current_deadline = deadline
13
+ Smith.scoped_artifacts = scoped_store
14
+ end
15
+
16
+ def teardown_thread
17
+ Smith::Tool.current_guardrails = nil
18
+ Smith::Tool.current_deadline = nil
19
+ Smith.scoped_artifacts = nil
20
+ end
21
+ end
22
+ # rubocop:enable Style/RedundantStructKeywordInit
23
+ end
24
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent_result"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module BudgetIntegration
@@ -29,7 +29,7 @@ module Smith
29
29
  session_messages: snapshot_value(@session_messages || []),
30
30
  tool_results: snapshot_value(@tool_results || []),
31
31
  state: @state,
32
- transition_name: transition.name
32
+ transition: transition
33
33
  )
34
34
  end
35
35