agent-workflow-sdk 0.4.0__tar.gz → 0.5.0__tar.gz

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 (174) hide show
  1. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/CHANGELOG.md +25 -0
  2. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/PKG-INFO +3 -2
  3. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/README.md +2 -1
  4. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/__init__.py +3 -1
  5. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/state.py +60 -0
  6. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/state.md +29 -0
  7. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/long-running-loops.md +22 -0
  8. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/state.md +1 -0
  9. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api.md +1 -0
  10. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/glossary.md +1 -1
  11. agent_workflow_sdk-0.5.0/examples/dual_state_search.py +188 -0
  12. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/pyproject.toml +1 -1
  13. agent_workflow_sdk-0.5.0/tests/test_reducers.py +168 -0
  14. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.env.example +0 -0
  15. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/ci.yml +0 -0
  19. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/docs.yml +0 -0
  20. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/release.yml +0 -0
  21. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.gitignore +0 -0
  22. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/CONTRIBUTING.md +0 -0
  23. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/DESIGN.md +0 -0
  24. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/LICENSE +0 -0
  25. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/NOTICE +0 -0
  26. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/SECURITY.md +0 -0
  27. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/_pg.py +0 -0
  28. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/__init__.py +0 -0
  29. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/_http.py +0 -0
  30. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/anthropic.py +0 -0
  31. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/base.py +0 -0
  32. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/claude_code.py +0 -0
  33. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/cli_exec.py +0 -0
  34. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/codex.py +0 -0
  35. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/kiro.py +0 -0
  36. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/ollama.py +0 -0
  37. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/openai.py +0 -0
  38. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/__init__.py +0 -0
  39. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/_serde.py +0 -0
  40. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/base.py +0 -0
  41. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/file.py +0 -0
  42. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/memory.py +0 -0
  43. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/postgres.py +0 -0
  44. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/redis.py +0 -0
  45. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/sqlite.py +0 -0
  46. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/compiled.py +0 -0
  47. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/__init__.py +0 -0
  48. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/memory.py +0 -0
  49. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/postgres.py +0 -0
  50. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/queue.py +0 -0
  51. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/records.py +0 -0
  52. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/registry.py +0 -0
  53. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/worker.py +0 -0
  54. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/errors.py +0 -0
  55. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/events.py +0 -0
  56. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/graph.py +0 -0
  57. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/interrupts.py +0 -0
  58. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/observability.py +0 -0
  59. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/otel.py +0 -0
  60. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/__init__.py +0 -0
  61. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/effects.py +0 -0
  62. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/gate.py +0 -0
  63. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/idempotent.py +0 -0
  64. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/loop.py +0 -0
  65. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/resilience.py +0 -0
  66. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/tool_loop.py +0 -0
  67. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/watch.py +0 -0
  68. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prometheus.py +0 -0
  69. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/py.typed +0 -0
  70. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/redaction.py +0 -0
  71. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/runtime.py +0 -0
  72. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/server/__init__.py +0 -0
  73. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/server/app.py +0 -0
  74. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/__init__.py +0 -0
  75. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/_util.py +0 -0
  76. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/base.py +0 -0
  77. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/memory.py +0 -0
  78. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/postgres.py +0 -0
  79. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/telemetry.py +0 -0
  80. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/agent-backends.md +0 -0
  81. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/llm-backends.md +0 -0
  82. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/overview.md +0 -0
  83. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/permissions.md +0 -0
  84. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/retries.md +0 -0
  85. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/context.md +0 -0
  86. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/execution.md +0 -0
  87. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/nodes-and-edges.md +0 -0
  88. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/running.md +0 -0
  89. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/subgraphs.md +0 -0
  90. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/checkpointing.md +0 -0
  91. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/control-plane.md +0 -0
  92. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/human-in-the-loop.md +0 -0
  93. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/observability.md +0 -0
  94. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/quality-gates.md +0 -0
  95. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/store.md +0 -0
  96. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/installation.md +0 -0
  97. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/mental-model.md +0 -0
  98. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/overview.md +0 -0
  99. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/quickstart.md +0 -0
  100. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/index.md +0 -0
  101. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/prebuilt.md +0 -0
  102. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/tutorial-qa-agent.md +0 -0
  103. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/backends.md +0 -0
  104. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/checkpoint.md +0 -0
  105. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/compiled.md +0 -0
  106. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/controlplane.md +0 -0
  107. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/errors.md +0 -0
  108. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/events.md +0 -0
  109. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/gate.md +0 -0
  110. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/graph.md +0 -0
  111. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/interrupts.md +0 -0
  112. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/observability.md +0 -0
  113. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/permissions.md +0 -0
  114. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/prebuilt.md +0 -0
  115. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/runtime.md +0 -0
  116. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/server.md +0 -0
  117. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/store.md +0 -0
  118. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/watch.md +0 -0
  119. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/agents_demo.py +0 -0
  120. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/codebase_qa_ollama.py +0 -0
  121. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/hello_graph.py +0 -0
  122. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/hitl_permission.py +0 -0
  123. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/idempotent_effect.py +0 -0
  124. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/mixed_backends.py +0 -0
  125. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/optimize_loop.py +0 -0
  126. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/quality_gate.py +0 -0
  127. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/research_plan_implement_review.py +0 -0
  128. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/serve_control_plane.py +0 -0
  129. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/telemetry_demo.py +0 -0
  130. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/tool_loop_ollama.py +0 -0
  131. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/watch_until_done.py +0 -0
  132. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/mkdocs.yml +0 -0
  133. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/conftest.py +0 -0
  134. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_anthropic_backend.py +0 -0
  135. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_backend_lifecycle.py +0 -0
  136. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_checkpoint_contract.py +0 -0
  137. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_cli_backends.py +0 -0
  138. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_concurrency.py +0 -0
  139. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_controlplane.py +0 -0
  140. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_effects.py +0 -0
  141. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_gate.py +0 -0
  142. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_graph_engine.py +0 -0
  143. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_hitl_permission.py +0 -0
  144. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_http_retry.py +0 -0
  145. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_idempotent.py +0 -0
  146. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_input_validation.py +0 -0
  147. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_kiro_backend.py +0 -0
  148. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_live_backends.py +0 -0
  149. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_observability.py +0 -0
  150. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_ollama_backend.py +0 -0
  151. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_openai_backend.py +0 -0
  152. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_otel.py +0 -0
  153. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_permission_policy.py +0 -0
  154. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_checkpointer.py +0 -0
  155. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_runqueue.py +0 -0
  156. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_store.py +0 -0
  157. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_loop.py +0 -0
  158. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_no_httpx.py +0 -0
  159. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prometheus.py +0 -0
  160. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_provider_resilience.py +0 -0
  161. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_redaction.py +0 -0
  162. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_redis_checkpointer.py +0 -0
  163. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_resilience.py +0 -0
  164. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_run_timeout.py +0 -0
  165. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_server.py +0 -0
  166. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_sqlite_checkpointer.py +0 -0
  167. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_state_isolation.py +0 -0
  168. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_step_atomicity.py +0 -0
  169. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_store.py +0 -0
  170. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_subgraph.py +0 -0
  171. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_sync_facade.py +0 -0
  172. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_telemetry.py +0 -0
  173. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_tool_loop.py +0 -0
  174. {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_watch.py +0 -0
@@ -4,6 +4,31 @@ All notable changes to `agent-workflow-sdk` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project aims to follow [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.5.0] - 2026-10-04
8
+
9
+ ### Added
10
+
11
+ - `by_key(policy)` reducer (`agentflow.state`, re-exported from `agentflow`):
12
+ a reducer for a dict channel whose fields each fold with their own reducer.
13
+ Where `merge` applies one last-wins policy to a whole dict, `by_key` lets a
14
+ single structured channel hold fields that accumulate differently — e.g. one
15
+ `search` channel where `intents` / `candidates` / `verified` grow via
16
+ `append` while `events` / `incumbent` are replaced via `last`. This is the
17
+ core primitive a dual-state search/optimization layer (single planner,
18
+ concurrent workers, a deterministic promotion boundary) builds on; the
19
+ fan-out, state isolation, checkpoints, Store, and quality gates it also needs
20
+ already exist. See the dual-state search recipe and
21
+ `examples/dual_state_search.py`.
22
+
23
+ ### Notes
24
+
25
+ - `by_key` folding is one level of structure, not a recursive deep-merge: each
26
+ field's value is combined whole by its reducer (so `last` on a field replaces
27
+ the whole object, it does not patch one of its keys). Writing a field absent
28
+ from the policy raises `KeyError`, mirroring how the engine rejects writes to
29
+ undeclared top-level channels. Use it for distinct fields that accumulate
30
+ differently, not for concurrent partial patches of the same nested object.
31
+
7
32
  ## [0.4.0] - 2026-10-03
8
33
 
9
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-workflow-sdk
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Async-first SDK for orchestrating local coding-agent CLIs (Claude Code, Codex, Kiro) and LLMs as durable, graph-based workflows.
5
5
  Project-URL: Homepage, https://github.com/dankosorkin/agent-workflow-sdk
6
6
  Project-URL: Repository, https://github.com/dankosorkin/agent-workflow-sdk
@@ -188,7 +188,8 @@ shows the iterate-until-converged loop.
188
188
  State is a `TypedDict` subclass of `State`. Each field is a channel; annotate
189
189
  it with a reducer that folds each node's update into the current value. A
190
190
  field without a reducer uses `last` (last-value-wins). Built-in reducers:
191
- `last`, `append`, `add`, `merge`, `union`. A node returns a partial update; it
191
+ `last`, `append`, `add`, `merge`, `union`, and `by_key` (a dict channel whose
192
+ fields each fold with their own reducer). A node returns a partial update; it
192
193
  never mutates the state it was given.
193
194
 
194
195
  ### Nodes and edges
@@ -139,7 +139,8 @@ shows the iterate-until-converged loop.
139
139
  State is a `TypedDict` subclass of `State`. Each field is a channel; annotate
140
140
  it with a reducer that folds each node's update into the current value. A
141
141
  field without a reducer uses `last` (last-value-wins). Built-in reducers:
142
- `last`, `append`, `add`, `merge`, `union`. A node returns a partial update; it
142
+ `last`, `append`, `add`, `merge`, `union`, and `by_key` (a dict channel whose
143
+ fields each fold with their own reducer). A node returns a partial update; it
143
144
  never mutates the state it was given.
144
145
 
145
146
  ### Nodes and edges
@@ -90,6 +90,7 @@ from agentflow.state import (
90
90
  State,
91
91
  add,
92
92
  append,
93
+ by_key,
93
94
  last,
94
95
  merge,
95
96
  union,
@@ -97,7 +98,7 @@ from agentflow.state import (
97
98
  from agentflow.store import Item, MemoryStore, Store
98
99
  from agentflow.telemetry import JsonlTelemetry, MultiHooks
99
100
 
100
- __version__ = "0.4.0"
101
+ __version__ = "0.5.0"
101
102
 
102
103
  __all__ = [
103
104
  "__version__",
@@ -109,6 +110,7 @@ __all__ = [
109
110
  "append",
110
111
  "add",
111
112
  "merge",
113
+ "by_key",
112
114
  "union",
113
115
  # graph
114
116
  "Graph",
@@ -57,6 +57,7 @@ __all__ = [
57
57
  "append",
58
58
  "add",
59
59
  "merge",
60
+ "by_key",
60
61
  "union",
61
62
  "channels_from_schema",
62
63
  "apply_update",
@@ -129,6 +130,65 @@ def merge(current: Any, update: Any) -> dict:
129
130
  return base
130
131
 
131
132
 
133
+ def by_key(policy: Mapping[str, Reducer]) -> Reducer:
134
+ """Build a reducer for a dict channel that folds each key with its own reducer.
135
+
136
+ Where :func:`merge` applies one policy (last-wins) to a whole dict, this
137
+ lets a single structured channel hold keys that accumulate differently —
138
+ for example an ``intents`` list that grows with :func:`append` beside an
139
+ ``events`` list that is replaced with :func:`last` and an ``incumbent`` that
140
+ is overwritten whole::
141
+
142
+ search = by_key({
143
+ "intents": append, # grow
144
+ "candidates": append, # grow (concurrent workers each add one)
145
+ "verified": append, # grow
146
+ "events": last, # replace (planner clears what it consumed)
147
+ "incumbent": last, # overwrite the current best, whole
148
+ })
149
+
150
+ class OptState(State):
151
+ search: Annotated[dict, search]
152
+
153
+ Folding rules, given ``current`` (``None`` is treated as ``{}``) and an
154
+ ``update`` dict:
155
+
156
+ - Only keys present in ``update`` are folded; a key absent from ``update``
157
+ keeps its ``current`` value untouched.
158
+ - Each folded key ``k`` is combined as ``policy[k](current.get(k),
159
+ update[k])`` — so ``{"events": []}`` genuinely clears ``events`` (that is
160
+ a ``last`` of an empty list, not a no-op), while ``{}`` or ``None`` is a
161
+ no-op returning a copy of ``current``.
162
+ - A key in ``update`` that is not in ``policy`` raises :class:`KeyError`.
163
+ Writing an undeclared field is a bug (usually a typo), mirroring how the
164
+ engine rejects writes to undeclared top-level channels.
165
+ - A key in ``current`` that is not in ``policy`` is passed through
166
+ unchanged; the policy only governs keys an update actually writes.
167
+
168
+ The value under each key is combined *whole* by that key's reducer; this is
169
+ one level of structure, not a recursive deep-merge. It is for distinct keys
170
+ that accumulate differently, not for concurrent partial patches of the same
171
+ nested object.
172
+ """
173
+ policy = dict(policy)
174
+
175
+ def _by_key(current: Any, update: Any) -> dict:
176
+ base = dict(current) if current is not None else {}
177
+ if not update:
178
+ return base
179
+ for key, value in update.items():
180
+ reducer = policy.get(key)
181
+ if reducer is None:
182
+ raise KeyError(
183
+ f"by_key update wrote field {key!r} with no reducer in policy; "
184
+ f"declared fields: {sorted(policy)}"
185
+ )
186
+ base[key] = reducer(base.get(key), value)
187
+ return base
188
+
189
+ return _by_key
190
+
191
+
132
192
  def union(current: Any, update: Any) -> set:
133
193
  """Set union. ``None`` current is the empty set."""
134
194
  base = set(current) if current is not None else set()
@@ -31,6 +31,35 @@ A field annotated `Annotated[type, reducer]` uses that reducer. A bare field use
31
31
 
32
32
  `append` extends when the update is a list or tuple, and appends a single element otherwise. Any pure `(current, update) -> new` is a valid reducer — the built-ins are just the common ones. A reducer must be pure and must treat a `None` current as its empty value.
33
33
 
34
+ ## Structured channels with `by_key`
35
+
36
+ `merge` folds a whole dict with one policy (last-wins). When one dict channel holds fields that must accumulate differently, `by_key` gives each field its own reducer:
37
+
38
+ ```python
39
+ from agentflow import State, append, by_key, last
40
+ from typing import Annotated
41
+
42
+ search = by_key({
43
+ "intents": append, # grow
44
+ "candidates": append, # grow (concurrent workers each add one)
45
+ "events": last, # replace
46
+ "incumbent": last, # overwrite the current best, whole
47
+ })
48
+
49
+ class OptState(State):
50
+ search: Annotated[dict, search]
51
+ ```
52
+
53
+ A node writes the fields it changes, and each is folded by that field's reducer. `{"search": {"candidates": [c]}}` appends one candidate; `{"search": {"events": []}}` clears events. Fields a node does not write are left untouched, so two nodes in one super-step writing different fields never clobber each other.
54
+
55
+ Three rules worth stating plainly:
56
+
57
+ - A field in the update that is not in the policy raises `KeyError`. Writing an undeclared field is a bug, just like writing an undeclared top-level channel.
58
+ - Each field's value is folded whole by its reducer. This is one level of structure, not a recursive deep-merge: `last` on `incumbent` replaces the whole object, it does not patch one of its keys.
59
+ - Use `by_key` for distinct fields that accumulate differently, not for concurrent partial patches of the same nested object.
60
+
61
+ This is the channel behind a search or optimization loop, where a planner appends intents, concurrent workers append candidates, and a verifier replaces events and overwrites the incumbent whole. See the [dual-state search recipe](../patterns/long-running-loops.md#recipe-dual-state-search-with-a-planner-and-a-promotion-boundary) and `examples/dual_state_search.py`.
62
+
34
63
  ## Nodes return deltas, not absolutes
35
64
 
36
65
  A node returns a partial update — a dict of channel names to values — and the engine folds each value through that channel's reducer:
@@ -64,6 +64,28 @@ flowchart TD
64
64
 
65
65
  Two things matter here. First, reducers do the merging: give the aggregate channel an `append` or `merge` reducer so the parallel updates fold together deterministically instead of racing. Second, a super-step is all-or-nothing — if any branch raises, the whole step is discarded and no checkpoint is written, so a resume never sees a half-merged aggregate. Bound the fan-out with `compile(max_node_concurrency=...)` if the branches hit a shared resource.
66
66
 
67
+ ## Recipe: dual-state search with a planner and a promotion boundary
68
+
69
+ A search or optimization loop separates proposing from judging. One structured channel holds the whole search, and a single `planner` is the only writer of the proposals. Several workers run concurrently, each producing one candidate. A single deterministic `verifier` is the promotion boundary: it checks candidates, overwrites the current best whole, and emits events the router reacts to.
70
+
71
+ ```mermaid
72
+ flowchart LR
73
+ START --> planner --> dispatch{runnable?}
74
+ dispatch -- fan out --> workers
75
+ workers --> verifier
76
+ verifier -- improved --> planner
77
+ verifier -- done --> END
78
+ ```
79
+
80
+ The reason this needs [`by_key`](../concepts/state.md#structured-channels-with-by_key): the one `search` channel holds fields that fold differently — `intents` and `candidates` and `verified` grow with `append`, `events` is replaced with `last`, `incumbent` is overwritten whole with `last`. A single `merge` cannot express that; `by_key` gives each field its own reducer, so concurrent workers appending candidates never clobber the planner's intents.
81
+
82
+ Two subtleties this recipe makes you confront, both consequences of reducers, not bugs:
83
+
84
+ - An `append` channel does not clear when you write `[]` to it — `append(current, [])` adds nothing. To drain one, have the consumer work off what it has not yet handled (key by id) rather than relying on a clear.
85
+ - `intents` accumulates across rounds, so dispatch must compute a *runnable* subset (proposed minus already verified) instead of fanning out the raw list. This is the frontier idea: runnable work is a function of state, not the whole backlog.
86
+
87
+ `examples/dual_state_search.py` is a runnable, offline version — a planner proposing strategies, workers scoring them concurrently, and a verifier promoting the best. The domain types (intents, candidates, verified, incumbent) live in the consumer; the core provides only the `by_key` channel they fold through.
88
+
67
89
  ## Recipe: budget-aware stopping
68
90
 
69
91
  An autonomous run needs a ceiling — a router that stops not only on quality but on cost. Keep the budget where it belongs: `attempts` is a decision input that must survive a resume, so it lives in state; wall-clock time and token counts are non-deterministic, so keep them in [`RunMetrics`](../durability/observability.md) or your own hook rather than in the checkpoint. Putting a timestamp in state means a resume would "replay" a time from the past and mislead the router.
@@ -12,4 +12,5 @@ State schemas, channels, and the built-in reducers. See the [State and reducers]
12
12
  - append
13
13
  - add
14
14
  - merge
15
+ - by_key
15
16
  - union
@@ -15,6 +15,7 @@ class MyState(State):
15
15
  | `append` | list concatenation |
16
16
  | `add` | numeric sum |
17
17
  | `merge` | dict merge (update wins) |
18
+ | `by_key` | per-key reducers for one dict channel |
18
19
  | `union` | set union |
19
20
 
20
21
  ## Graph — [guide](../concepts/nodes-and-edges.md)
@@ -81,7 +81,7 @@ How an [agent backend](#agent-backend) answers a tool-authorization request. `Al
81
81
  A checked "good enough?" decision added with [`add_quality_gate`](../durability/quality-gates.md): an [evaluator](#evaluator) plus three-way routing (accept / revise / escalate) and an attempt budget. See [quality gates](../durability/quality-gates.md).
82
82
 
83
83
  ### Reducer
84
- A pure `(current, update) -> new` function that folds a node's update into a [channel](#channel). `last`, `append`, `add`, `merge`, `union`, or custom. See [state and reducers](../concepts/state.md).
84
+ A pure `(current, update) -> new` function that folds a node's update into a [channel](#channel). `last`, `append`, `add`, `merge`, `by_key`, `union`, or custom. See [state and reducers](../concepts/state.md).
85
85
 
86
86
  ### Resume
87
87
  Continuing a suspended or crashed run from its last [checkpoint](#checkpoint), via `app.resume(thread, value)`. Requires a [checkpointer](#checkpointer). See [human-in-the-loop](../durability/human-in-the-loop.md).
@@ -0,0 +1,188 @@
1
+ # Copyright 2026 Daniel Sorkin
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Dual-state search with a by_key channel: planner, workers, verifier.
16
+
17
+ Run: python examples/dual_state_search.py
18
+
19
+ Shows the shape a search/optimization loop takes on top of the core: one
20
+ structured ``search`` channel whose fields accumulate differently, folded by
21
+ ``by_key``. A single planner is the only writer of ``intents``; several workers
22
+ run concurrently in one super-step and each appends a ``candidate``; a single
23
+ deterministic verifier is the promotion boundary — it appends to ``verified``,
24
+ replaces the current ``incumbent`` whole, and clears the ``events`` it handled.
25
+
26
+ Everything here is a plain function so the example runs on the dependency-free
27
+ core — no backend, no network. A real planner would call an LLM/agent and a
28
+ real worker would run one narrow experiment; the state plumbing is identical.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ from typing import Annotated
35
+
36
+ from agentflow import END, START, Graph, State, append, by_key, last
37
+
38
+ # One structured channel, each field with its own fold policy. intents and
39
+ # candidates and verified grow (append); events is replaced each step (last);
40
+ # incumbent is overwritten whole when a better candidate is promoted (last).
41
+ search_channel = by_key(
42
+ {
43
+ "intents": append,
44
+ "candidates": append,
45
+ "verified": append,
46
+ "events": last,
47
+ "incumbent": last,
48
+ }
49
+ )
50
+
51
+
52
+ class SearchState(State):
53
+ search: Annotated[dict, search_channel]
54
+ rounds: Annotated[int, last]
55
+
56
+
57
+ # A tiny deterministic "objective": lower is better. Each strategy name maps to
58
+ # a score, so the run is fully reproducible without any model.
59
+ STRATEGY_SCORES = {
60
+ "try_index": 4.0,
61
+ "reorder_stages": 7.0,
62
+ "push_match": 2.5,
63
+ }
64
+
65
+
66
+ async def planner(state: SearchState, ctx) -> dict:
67
+ """The only writer of intents. Proposes strategies not yet verified."""
68
+ search = state["search"]
69
+ tried = {v["strategy"] for v in search.get("verified", [])}
70
+ proposals = [s for s in STRATEGY_SCORES if s not in tried]
71
+ # propose at most two per round, so re-planning happens more than once
72
+ new_intents = proposals[:2]
73
+ print(f"planner: proposing {new_intents or '(nothing new)'}")
74
+ return {
75
+ "search": {"intents": new_intents, "events": []}, # clear handled events
76
+ "rounds": state.get("rounds", 0) + 1,
77
+ }
78
+
79
+
80
+ def _worker(strategy: str):
81
+ """Build a worker node that runs one narrow experiment for a strategy."""
82
+
83
+ async def worker(state: SearchState, ctx) -> dict:
84
+ # a real worker would run an index/pipeline experiment here
85
+ candidate = {"strategy": strategy, "score": STRATEGY_SCORES[strategy]}
86
+ return {"search": {"candidates": [candidate]}}
87
+
88
+ worker.__name__ = f"worker_{strategy}"
89
+ return worker
90
+
91
+
92
+ async def verifier(state: SearchState, ctx) -> dict:
93
+ """Promotion boundary: deterministic, no model. Verifies new candidates,
94
+ promotes a new incumbent if one beats the current best, emits events.
95
+
96
+ Note ``candidates`` is an append channel: writing ``[]`` to it does not
97
+ clear it (``append`` of an empty list is a no-op). So the verifier works
98
+ off the candidates it has not verified yet, keyed by strategy, rather than
99
+ relying on a clear. This is the honest way to drain an append channel.
100
+ """
101
+ search = state["search"]
102
+ already = {v["strategy"] for v in search.get("verified", [])}
103
+ candidates = [c for c in search.get("candidates", []) if c["strategy"] not in already]
104
+ incumbent = search.get("incumbent")
105
+
106
+ verified = [{"strategy": c["strategy"], "score": c["score"]} for c in candidates]
107
+ best = min(candidates, key=lambda c: c["score"]) if candidates else None
108
+
109
+ updates: dict = {"search": {"verified": verified}}
110
+ if best is not None and (incumbent is None or best["score"] < incumbent["score"]):
111
+ updates["search"]["incumbent"] = best
112
+ updates["search"]["events"] = ["NEW_INCUMBENT"]
113
+ print(f"verifier: NEW_INCUMBENT {best['strategy']} score={best['score']}")
114
+ else:
115
+ updates["search"]["events"] = ["NO_IMPROVEMENT"]
116
+ print("verifier: no improvement this round")
117
+ return updates
118
+
119
+
120
+ def route_after_verifier(state: SearchState) -> str:
121
+ """Event-driven re-planning: loop back to the planner while there is still
122
+ something to try and we improved; stop otherwise or at a round cap."""
123
+ search = state["search"]
124
+ tried = {v["strategy"] for v in search.get("verified", [])}
125
+ exhausted = tried >= set(STRATEGY_SCORES)
126
+ if exhausted or state.get("rounds", 0) >= 5:
127
+ return "done"
128
+ if "NEW_INCUMBENT" in search.get("events", []):
129
+ return "replan"
130
+ return "done"
131
+
132
+
133
+ def dispatch(state: SearchState) -> list[str]:
134
+ """Fan out one worker per *runnable* intent, all in the next super-step.
135
+
136
+ ``intents`` is an append channel, so it accumulates every proposal ever
137
+ made. The runnable frontier is the subset not already verified — this is
138
+ why you compute a frontier instead of dispatching the raw intents list.
139
+ """
140
+ search = state["search"]
141
+ tried = {v["strategy"] for v in search.get("verified", [])}
142
+ runnable = [i for i in search.get("intents", []) if i in STRATEGY_SCORES and i not in tried]
143
+ return runnable or ["noop"]
144
+
145
+
146
+ async def noop(state: SearchState, ctx) -> dict:
147
+ return {"search": {"candidates": []}}
148
+
149
+
150
+ def build() -> Graph:
151
+ g = Graph(SearchState)
152
+ g.add_node("planner", planner)
153
+ for strategy in STRATEGY_SCORES:
154
+ g.add_node(f"worker_{strategy}", _worker(strategy))
155
+ g.add_node("noop", noop)
156
+ g.add_node("verifier", verifier)
157
+
158
+ g.add_edge(START, "planner")
159
+
160
+ # planner fans out to a worker per runnable intent (concurrent super-step)
161
+ worker_targets = {s: f"worker_{s}" for s in STRATEGY_SCORES}
162
+ worker_targets["noop"] = "noop"
163
+ g.add_conditional_edges("planner", dispatch, worker_targets)
164
+
165
+ # every worker leads into the single verifier
166
+ for strategy in STRATEGY_SCORES:
167
+ g.add_edge(f"worker_{strategy}", "verifier")
168
+ g.add_edge("noop", "verifier")
169
+
170
+ # verifier either re-plans or ends
171
+ g.add_conditional_edges("verifier", route_after_verifier, {"replan": "planner", "done": END})
172
+ return g
173
+
174
+
175
+ async def main() -> None:
176
+ app = build().compile()
177
+ out = await app.invoke({"search": {}, "rounds": 0})
178
+ search = out["search"]
179
+ print()
180
+ print(f"rounds run: {out['rounds']}")
181
+ print(f"verified: {[v['strategy'] for v in search['verified']]}")
182
+ print(f"best incumbent: {search['incumbent']}")
183
+ # candidates was cleared by the verifier each round; events holds the last
184
+ print(f"last events: {search['events']}")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ asyncio.run(main())
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-workflow-sdk"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Async-first SDK for orchestrating local coding-agent CLIs (Claude Code, Codex, Kiro) and LLMs as durable, graph-based workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,168 @@
1
+ # Copyright 2026 Daniel Sorkin
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Tests for the by_key per-key reducer.
16
+
17
+ Unit tests fold values through the reducer directly; the integration test
18
+ drives a real fan-out through the engine so two concurrent workers each add a
19
+ candidate to the same structured channel and the merge is deterministic.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Annotated
25
+
26
+ import pytest
27
+ from agentflow import END, START, Graph, State, append, by_key, last, merge
28
+
29
+ # --- unit: folding rules ---------------------------------------------------
30
+
31
+
32
+ def test_folds_each_key_with_its_own_reducer():
33
+ r = by_key({"intents": append, "events": last})
34
+ current = {"intents": ["i1", "i2"], "events": ["OLD"]}
35
+ out = r(current, {"intents": ["i3"], "events": ["NEW"]})
36
+ assert out == {"intents": ["i1", "i2", "i3"], "events": ["NEW"]}
37
+
38
+
39
+ def test_absent_key_is_left_untouched():
40
+ r = by_key({"intents": append, "events": last})
41
+ current = {"intents": ["i1"], "events": ["OLD"]}
42
+ # update touches only events; intents must survive unchanged
43
+ out = r(current, {"events": ["NEW"]})
44
+ assert out == {"intents": ["i1"], "events": ["NEW"]}
45
+
46
+
47
+ def test_empty_list_update_clears_via_last():
48
+ # {"events": []} is a real last([...], []) -> [], not a no-op
49
+ r = by_key({"events": last})
50
+ out = r({"events": ["A", "B"]}, {"events": []})
51
+ assert out == {"events": []}
52
+
53
+
54
+ def test_none_current_starts_empty():
55
+ r = by_key({"intents": append})
56
+ out = r(None, {"intents": ["i1"]})
57
+ assert out == {"intents": ["i1"]}
58
+
59
+
60
+ def test_empty_update_is_noop_copy():
61
+ r = by_key({"intents": append})
62
+ current = {"intents": ["i1"]}
63
+ for empty in ({}, None):
64
+ out = r(current, empty)
65
+ assert out == {"intents": ["i1"]}
66
+ assert out is not current # a copy, not the same object
67
+
68
+
69
+ def test_incumbent_last_overwrites_whole():
70
+ # the promotion case: incumbent is replaced whole, not deep-merged
71
+ r = by_key({"incumbent": last})
72
+ current = {"incumbent": {"obj": 10.0, "plan": "IXSCAN"}}
73
+ out = r(current, {"incumbent": {"obj": 7.5}})
74
+ assert out == {"incumbent": {"obj": 7.5}} # plan is gone, by design
75
+
76
+
77
+ def test_key_not_in_policy_raises():
78
+ r = by_key({"intents": append})
79
+ with pytest.raises(KeyError, match="incumbnet"):
80
+ r({}, {"incumbnet": {}}) # typo -> loud failure
81
+
82
+
83
+ def test_current_key_outside_policy_passes_through():
84
+ # a key already in current that the policy does not govern is preserved
85
+ # as long as the update does not write it
86
+ r = by_key({"intents": append})
87
+ out = r({"legacy": 1, "intents": ["i1"]}, {"intents": ["i2"]})
88
+ assert out == {"legacy": 1, "intents": ["i1", "i2"]}
89
+
90
+
91
+ def test_mixed_policy_with_merge_inside():
92
+ # a nested reducer can itself be merge (shallow) for a sub-dict field
93
+ r = by_key({"bounds": merge, "events": last})
94
+ current = {"bounds": {"lo": 1.0}, "events": ["x"]}
95
+ out = r(current, {"bounds": {"hi": 9.0}, "events": []})
96
+ assert out == {"bounds": {"lo": 1.0, "hi": 9.0}, "events": []}
97
+
98
+
99
+ # --- integration: concurrent fan-out into one by_key channel ---------------
100
+
101
+
102
+ SEARCH = by_key(
103
+ {
104
+ "intents": append,
105
+ "candidates": append,
106
+ "verified": append,
107
+ "events": last,
108
+ "incumbent": last,
109
+ }
110
+ )
111
+
112
+
113
+ class OptState(State):
114
+ search: Annotated[dict, SEARCH]
115
+
116
+
117
+ async def test_concurrent_workers_append_into_one_channel():
118
+ """Two workers in the same super-step each add a candidate; both survive.
119
+
120
+ This is the dual-state shape: distinct keys of one structured channel
121
+ accumulate differently (candidates grows via append, events replaced via
122
+ last), and concurrent writers do not clobber each other.
123
+ """
124
+ g = Graph(OptState)
125
+
126
+ async def planner(state, ctx):
127
+ return {"search": {"intents": ["try_index", "reorder"], "events": ["PLANNED"]}}
128
+
129
+ async def worker_a(state, ctx):
130
+ return {"search": {"candidates": [{"id": "a"}]}}
131
+
132
+ async def worker_b(state, ctx):
133
+ return {"search": {"candidates": [{"id": "b"}]}}
134
+
135
+ async def verifier(state, ctx):
136
+ cands = state["search"]["candidates"]
137
+ # promote a winner whole; clear the events the planner emitted
138
+ return {
139
+ "search": {
140
+ "verified": [{"from": len(cands)}],
141
+ "incumbent": {"obj": 1.0},
142
+ "events": [],
143
+ }
144
+ }
145
+
146
+ g.add_node("planner", planner)
147
+ g.add_node("worker_a", worker_a)
148
+ g.add_node("worker_b", worker_b)
149
+ g.add_node("verifier", verifier)
150
+ g.add_edge(START, "planner")
151
+ # fan-out both workers in one super-step
152
+ g.add_conditional_edges("planner", lambda s: ["a", "b"], {"a": "worker_a", "b": "worker_b"})
153
+ g.add_edge("worker_a", "verifier")
154
+ g.add_edge("worker_b", "verifier")
155
+ g.add_edge("verifier", END)
156
+
157
+ out = await g.compile().invoke({"search": {}})
158
+ search = out["search"]
159
+
160
+ # both workers' candidates merged (frontier order: a then b)
161
+ assert search["candidates"] == [{"id": "a"}, {"id": "b"}]
162
+ # planner's intents preserved through later super-steps
163
+ assert search["intents"] == ["try_index", "reorder"]
164
+ # verifier saw both candidates and promoted a whole incumbent
165
+ assert search["verified"] == [{"from": 2}]
166
+ assert search["incumbent"] == {"obj": 1.0}
167
+ # events were replaced, not appended (last): planner's PLANNED is gone
168
+ assert search["events"] == []