smith-agents 0.4.2 → 0.4.4

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -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/lifecycle.rb +30 -10
  7. data/lib/smith/agent/registry/introspection.rb +27 -0
  8. data/lib/smith/agent/registry.rb +22 -17
  9. data/lib/smith/agent/registry_binding.rb +43 -0
  10. data/lib/smith/agent.rb +3 -4
  11. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  12. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  13. data/lib/smith/version.rb +1 -1
  14. data/lib/smith/workflow/agent_result.rb +26 -0
  15. data/lib/smith/workflow/branch_env.rb +24 -0
  16. data/lib/smith/workflow/budget_integration.rb +2 -0
  17. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  18. data/lib/smith/workflow/deterministic_step.rb +32 -4
  19. data/lib/smith/workflow/durability.rb +38 -12
  20. data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +2 -0
  23. data/lib/smith/workflow/fanout_execution.rb +2 -0
  24. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  25. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  26. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  27. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  28. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  30. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  31. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  33. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  34. data/lib/smith/workflow/graph/targets.rb +5 -0
  35. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  36. data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
  37. data/lib/smith/workflow/graph.rb +16 -1
  38. data/lib/smith/workflow/graph_dsl.rb +4 -0
  39. data/lib/smith/workflow/guardrail_integration.rb +20 -3
  40. data/lib/smith/workflow/nested_execution.rb +5 -4
  41. data/lib/smith/workflow/optimization_state.rb +13 -0
  42. data/lib/smith/workflow/orchestration_state.rb +13 -0
  43. data/lib/smith/workflow/orchestrator_worker.rb +3 -15
  44. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  45. data/lib/smith/workflow/parallel.rb +6 -15
  46. data/lib/smith/workflow/parallel_execution.rb +2 -0
  47. data/lib/smith/workflow/persistence.rb +37 -6
  48. data/lib/smith/workflow/router.rb +15 -4
  49. data/lib/smith/workflow/run_result.rb +54 -0
  50. data/lib/smith/workflow/transition.rb +90 -4
  51. data/lib/smith/workflow/usage_entry.rb +30 -0
  52. data/lib/smith/workflow/worker_execution.rb +13 -0
  53. data/lib/smith/workflow.rb +40 -136
  54. data/lib/smith.rb +9 -0
  55. 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: 23c6a49b741d4ff6cfab0707ca1d917b484dc708106d1c892b4f90e2002e5b80
4
+ data.tar.gz: 72cfe59171b0f759f0312ff062f58c23aeb9a9f7e738f1603dfaf4a9174f9fbb
5
5
  SHA512:
6
- metadata.gz: 75d149a61196f2c0cd22748ff6d5a6c4530bc9d91c61b163c027e44d555b9fd0f1ca5ef0a283c6025d6906b6d984923265fba05190fa3da37a60cfe864e5f6f8
7
- data.tar.gz: 906bc56189df0f84ad62cb643ed8141a05780a328bf9a3770db55c575d71db86b94ad39e91517ba0e2d76f05942f84358dc234b2e685ad0701dba059bed65d36
6
+ metadata.gz: 245ac5f48372808860db62cc661bf9c9e5bf2a1a3bc4082533b16159b09c50b113bead696d4147e10e850fe0b7fff8cef775da17aa749fc4b7a5574058b765f7
7
+ data.tar.gz: 7837da6f0c9855f03a0936bdec22279ec6da9c8864fae27de8ab495a334b98654617147b36b7759d171dc19ad13ae80448b18fa3c7da4f16bf84f7c378c004bf
data/CHANGELOG.md CHANGED
@@ -6,7 +6,85 @@ 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.4] - 2026-07-10
10
+
11
+ Patch release for provider-safe workflow handoffs. Smith keeps accepted agent
12
+ outputs in durable session history while adapting only the next provider call
13
+ when a completed workflow stage would otherwise look like an unsupported
14
+ assistant prefill.
15
+
16
+ ### Fixed
17
+
18
+ - Adapt workflow-prepared provider input that ends with an accepted assistant
19
+ result by adding a non-persisted user continuation for the next agent call.
20
+ This preserves Smith session history while avoiding unsupported assistant
21
+ prefilling on provider models that require a user turn before completion.
22
+ Provider preparation now also reads string-keyed roles and content restored
23
+ through JSON host persistence. Explicit assistant-prefill seed messages remain
24
+ unchanged unless they match Smith's recorded accepted workflow output.
25
+
26
+ ### Verification
27
+
28
+ - Default suite: 932 examples, 0 failures.
29
+ - Practical gem-level JSON persistence/restore probe with a 25-branch parallel
30
+ handoff, provider-safe message ordering, non-persisted continuation, and
31
+ explicit assistant-prefill preservation.
32
+ - Smith Runtime host verification on Ruby 4.0.1 and Rails 8.1.3: 139 tests,
33
+ 391 assertions, 0 failures, plus a process-level restored workflow run.
34
+
35
+ ## [0.4.3] - 2026-07-05
36
+
37
+ ### Documentation
38
+
39
+ - Clarify Smith's repair and wait-style loop boundaries: `retry_on` and
40
+ `optimize` are executable today, deterministic repair and guarded re-entry
41
+ are not native first-class contracts yet, and durable polling/wait semantics
42
+ remain host-owned unless an explicit wait contract exists.
43
+
44
+ ### Added
45
+
46
+ - Static graph-inspection contracts for `optimize` and `orchestrate`
47
+ transitions, including bounded loop/delegation settings, schema labels, output
48
+ contracts, exit policies, dispatch semantics, and transition-level resume
49
+ guarantees.
50
+ - `Workflow.runtime_readiness`, a static diagnostic report that separates graph
51
+ topology validity from runtime binding readiness without executing agents,
52
+ tools, providers, jobs, or persistence.
53
+ - Runtime-readiness diagnostics for unresolved, invalid, lazy/uninspectable,
54
+ model-less, and model-required agent bindings across execute, route,
55
+ optimize, orchestrate, nested, and fan-out workflow shapes.
56
+ - Runtime-readiness metrics now expose direct counts and transitive counts folded
57
+ in from nested workflows.
58
+ - `Smith::Agent::Registry.binding_for` and `.bindings` expose non-resolving
59
+ registry inspection for diagnostics and host cleanup.
60
+ - Richer fan-out transition snapshot metadata: branch count, join state,
61
+ ordered branch list, output contract, resume contract, and per-branch result
62
+ contracts for named branch-result output.
63
+ - Direct doctor coverage for registered agent model-profile checks, including
64
+ static primary and static fallback models, making safe-default model shaping
65
+ explicit before hosts rely on runtime behavior.
66
+
67
+ ### Changed
68
+
69
+ - `smith doctor --profile rails_persistence` now reports the full optional
70
+ persistence capability surface (`store_versioned`, `record_heartbeat`, and
71
+ `last_heartbeat`) instead of checking optimistic locking only.
72
+ - Workflow runtime value objects now live in dedicated files while preserving
73
+ the existing public constants (`Smith::Workflow::RunResult`,
74
+ `AgentResult`, `UsageEntry`, `BranchEnv`, and internal execution helpers).
75
+ - Release documentation now reflects the current heartbeat optional-capability
76
+ contract and the RubyLLM integration boundary.
77
+
78
+ ### Test coverage
79
+
80
+ - Default suite: 926 examples, 0 failures.
81
+ - Practical gem-level execution probe covering 30 varied workflows across
82
+ strict/lax idempotency, same-agent parallel branches, heterogeneous fan-out,
83
+ retry metadata, optimizer contracts, and orchestrator-worker flows.
84
+ - Smith Studio host verification against the local Smith checkout: 186 runtime
85
+ tests and a 30-scenario generated-class lifecycle proof gate.
86
+ - Built `pkg/smith-agents-0.4.3.gem` and smoke-tested `require "smith"` from
87
+ the unpacked package.
10
88
 
11
89
  ## [0.4.2] - 2026-07-02
12
90
 
@@ -128,7 +206,7 @@ This release tracks two thematic refactors that together harden the agent-invoca
128
206
  - `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
207
  - `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
208
  - `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.
209
+ - `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
210
  - `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
211
  - `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
212
  - `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 +266,4 @@ This release tracks two thematic refactors that together harden the agent-invoca
188
266
 
189
267
  ## [0.1.0] - Initial public-track release
190
268
 
191
- Initial pre-release tagged for the Hadithi consumer's local-path dependency. No formal changelog prior to the Phase A/B refactor.
269
+ 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.4", 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.
@@ -3,6 +3,10 @@
3
3
  module Smith
4
4
  class Agent
5
5
  module Lifecycle
6
+ WORKFLOW_CONTINUATION_MESSAGE =
7
+ "Use the preceding assistant result as input and perform your assigned workflow step."
8
+ private_constant :WORKFLOW_CONTINUATION_MESSAGE
9
+
6
10
  TRANSIENT_ERRORS = [
7
11
  RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
8
12
  RubyLLM::OverloadedError, RubyLLM::RateLimitError
@@ -111,6 +115,7 @@ module Smith
111
115
  def add_prepared_input(chat, prepared_input)
112
116
  return unless prepared_input
113
117
 
118
+ prepared_input = provider_safe_prepared_input(prepared_input)
114
119
  system_messages, other_messages = prepared_input.partition do |message|
115
120
  message_role(message) == :system
116
121
  end
@@ -119,6 +124,22 @@ module Smith
119
124
  other_messages.each { |message| chat.add_message(message) }
120
125
  end
121
126
 
127
+ def provider_safe_prepared_input(prepared_input)
128
+ messages = prepared_input.to_a
129
+ return messages unless workflow_handoff?(messages)
130
+
131
+ messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
132
+ end
133
+
134
+ def workflow_handoff?(messages)
135
+ message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
136
+ return false unless message
137
+ return false unless message_role(message) == :assistant
138
+ return false unless defined?(@last_output) && !@last_output.nil?
139
+
140
+ message_content(message) == @last_output
141
+ end
142
+
122
143
  def merge_system_messages!(chat, prepared_system_messages)
123
144
  return prepared_system_messages.each { |message| chat.add_message(message) } unless chat.respond_to?(:messages)
124
145
 
@@ -143,19 +164,18 @@ module Smith
143
164
  end
144
165
 
145
166
  def message_role(message)
146
- if message.respond_to?(:role)
147
- message.role&.to_sym
148
- else
149
- message[:role]&.to_sym
150
- end
167
+ message_attribute(message, :role)&.to_sym
151
168
  end
152
169
 
153
170
  def message_content(message)
154
- if message.respond_to?(:content)
155
- message.content
156
- else
157
- message[:content]
158
- end
171
+ message_attribute(message, :content)
172
+ end
173
+
174
+ def message_attribute(message, name)
175
+ return message.public_send(name) if message.respond_to?(name)
176
+ return message[name] if message.respond_to?(:key?) && message.key?(name)
177
+
178
+ message[name.to_s]
159
179
  end
160
180
 
161
181
  def fallback_eligible?(error)
@@ -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