agent-workflow-sdk 0.3.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.3.0 → agent_workflow_sdk-0.5.0}/.github/workflows/docs.yml +11 -5
  2. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/CHANGELOG.md +50 -0
  3. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/DESIGN.md +17 -7
  4. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/PKG-INFO +7 -2
  5. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/README.md +2 -1
  6. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/__init__.py +3 -1
  7. agent_workflow_sdk-0.5.0/agentflow/server/__init__.py +46 -0
  8. agent_workflow_sdk-0.5.0/agentflow/server/app.py +205 -0
  9. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/state.py +60 -0
  10. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/state.md +29 -0
  11. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/control-plane.md +62 -1
  12. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/patterns/long-running-loops.md +22 -0
  13. agent_workflow_sdk-0.5.0/docs/reference/api/server.md +23 -0
  14. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/state.md +1 -0
  15. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api.md +1 -0
  16. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/glossary.md +1 -1
  17. agent_workflow_sdk-0.5.0/examples/dual_state_search.py +188 -0
  18. agent_workflow_sdk-0.5.0/examples/serve_control_plane.py +89 -0
  19. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/mkdocs.yml +1 -0
  20. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/pyproject.toml +4 -1
  21. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_otel.py +4 -2
  22. agent_workflow_sdk-0.5.0/tests/test_reducers.py +168 -0
  23. agent_workflow_sdk-0.5.0/tests/test_server.py +172 -0
  24. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.env.example +0 -0
  25. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.github/workflows/ci.yml +0 -0
  29. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.github/workflows/release.yml +0 -0
  30. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/.gitignore +0 -0
  31. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/CONTRIBUTING.md +0 -0
  32. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/LICENSE +0 -0
  33. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/NOTICE +0 -0
  34. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/SECURITY.md +0 -0
  35. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/_pg.py +0 -0
  36. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/__init__.py +0 -0
  37. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/_http.py +0 -0
  38. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/anthropic.py +0 -0
  39. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/base.py +0 -0
  40. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/claude_code.py +0 -0
  41. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/cli_exec.py +0 -0
  42. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/codex.py +0 -0
  43. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/kiro.py +0 -0
  44. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/ollama.py +0 -0
  45. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/openai.py +0 -0
  46. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/__init__.py +0 -0
  47. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/_serde.py +0 -0
  48. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/base.py +0 -0
  49. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/file.py +0 -0
  50. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/memory.py +0 -0
  51. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/postgres.py +0 -0
  52. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/redis.py +0 -0
  53. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/sqlite.py +0 -0
  54. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/compiled.py +0 -0
  55. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/__init__.py +0 -0
  56. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/memory.py +0 -0
  57. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/postgres.py +0 -0
  58. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/queue.py +0 -0
  59. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/records.py +0 -0
  60. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/registry.py +0 -0
  61. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/worker.py +0 -0
  62. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/errors.py +0 -0
  63. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/events.py +0 -0
  64. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/graph.py +0 -0
  65. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/interrupts.py +0 -0
  66. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/observability.py +0 -0
  67. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/otel.py +0 -0
  68. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/__init__.py +0 -0
  69. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/effects.py +0 -0
  70. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/gate.py +0 -0
  71. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/idempotent.py +0 -0
  72. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/loop.py +0 -0
  73. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/resilience.py +0 -0
  74. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/tool_loop.py +0 -0
  75. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/watch.py +0 -0
  76. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/prometheus.py +0 -0
  77. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/py.typed +0 -0
  78. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/redaction.py +0 -0
  79. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/runtime.py +0 -0
  80. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/store/__init__.py +0 -0
  81. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/store/_util.py +0 -0
  82. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/store/base.py +0 -0
  83. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/store/memory.py +0 -0
  84. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/store/postgres.py +0 -0
  85. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/agentflow/telemetry.py +0 -0
  86. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/backends/agent-backends.md +0 -0
  87. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/backends/llm-backends.md +0 -0
  88. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/backends/overview.md +0 -0
  89. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/backends/permissions.md +0 -0
  90. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/backends/retries.md +0 -0
  91. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/context.md +0 -0
  92. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/execution.md +0 -0
  93. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/nodes-and-edges.md +0 -0
  94. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/running.md +0 -0
  95. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/concepts/subgraphs.md +0 -0
  96. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/checkpointing.md +0 -0
  97. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/human-in-the-loop.md +0 -0
  98. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/observability.md +0 -0
  99. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/quality-gates.md +0 -0
  100. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/durability/store.md +0 -0
  101. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/get-started/installation.md +0 -0
  102. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/get-started/mental-model.md +0 -0
  103. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/get-started/overview.md +0 -0
  104. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/get-started/quickstart.md +0 -0
  105. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/index.md +0 -0
  106. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/patterns/prebuilt.md +0 -0
  107. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/patterns/tutorial-qa-agent.md +0 -0
  108. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/backends.md +0 -0
  109. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/checkpoint.md +0 -0
  110. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/compiled.md +0 -0
  111. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/controlplane.md +0 -0
  112. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/errors.md +0 -0
  113. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/events.md +0 -0
  114. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/gate.md +0 -0
  115. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/graph.md +0 -0
  116. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/interrupts.md +0 -0
  117. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/observability.md +0 -0
  118. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/permissions.md +0 -0
  119. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/prebuilt.md +0 -0
  120. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/runtime.md +0 -0
  121. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/store.md +0 -0
  122. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/watch.md +0 -0
  123. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/agents_demo.py +0 -0
  124. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/codebase_qa_ollama.py +0 -0
  125. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/hello_graph.py +0 -0
  126. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/hitl_permission.py +0 -0
  127. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/idempotent_effect.py +0 -0
  128. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/mixed_backends.py +0 -0
  129. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/optimize_loop.py +0 -0
  130. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/quality_gate.py +0 -0
  131. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/research_plan_implement_review.py +0 -0
  132. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/telemetry_demo.py +0 -0
  133. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/tool_loop_ollama.py +0 -0
  134. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/examples/watch_until_done.py +0 -0
  135. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/conftest.py +0 -0
  136. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_anthropic_backend.py +0 -0
  137. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_backend_lifecycle.py +0 -0
  138. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_checkpoint_contract.py +0 -0
  139. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_cli_backends.py +0 -0
  140. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_concurrency.py +0 -0
  141. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_controlplane.py +0 -0
  142. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_effects.py +0 -0
  143. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_gate.py +0 -0
  144. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_graph_engine.py +0 -0
  145. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_hitl_permission.py +0 -0
  146. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_http_retry.py +0 -0
  147. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_idempotent.py +0 -0
  148. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_input_validation.py +0 -0
  149. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_kiro_backend.py +0 -0
  150. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_live_backends.py +0 -0
  151. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_observability.py +0 -0
  152. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_ollama_backend.py +0 -0
  153. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_openai_backend.py +0 -0
  154. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_permission_policy.py +0 -0
  155. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_checkpointer.py +0 -0
  156. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_runqueue.py +0 -0
  157. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_store.py +0 -0
  158. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_loop.py +0 -0
  159. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_no_httpx.py +0 -0
  160. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_prometheus.py +0 -0
  161. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_provider_resilience.py +0 -0
  162. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_redaction.py +0 -0
  163. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_redis_checkpointer.py +0 -0
  164. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_resilience.py +0 -0
  165. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_run_timeout.py +0 -0
  166. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_sqlite_checkpointer.py +0 -0
  167. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_state_isolation.py +0 -0
  168. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_step_atomicity.py +0 -0
  169. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_store.py +0 -0
  170. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_subgraph.py +0 -0
  171. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_sync_facade.py +0 -0
  172. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_telemetry.py +0 -0
  173. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_tool_loop.py +0 -0
  174. {agent_workflow_sdk-0.3.0 → agent_workflow_sdk-0.5.0}/tests/test_watch.py +0 -0
@@ -1,12 +1,18 @@
1
1
  name: Docs
2
2
 
3
- # Build the mkdocs site and deploy it to GitHub Pages on a version tag, or on
4
- # demand from the Actions tab. Enable Pages once (Settings -> Pages -> Source:
5
- # GitHub Actions) for the deploy step to publish.
3
+ # Build the mkdocs site and deploy it to GitHub Pages when docs change on main,
4
+ # or on demand from the Actions tab. Triggering on main (not a tag) matches the
5
+ # github-pages environment's branch protection, which allows the default branch
6
+ # — a tag push is rejected by that rule. Enable Pages once
7
+ # (Settings -> Pages -> Source: GitHub Actions) for the deploy step to publish.
6
8
  on:
7
9
  push:
8
- tags:
9
- - "v*"
10
+ branches: [main]
11
+ paths:
12
+ - "docs/**"
13
+ - "mkdocs.yml"
14
+ - "agentflow/**"
15
+ - ".github/workflows/docs.yml"
10
16
  workflow_dispatch:
11
17
 
12
18
  # Allow one concurrent deploy; a newer run supersedes an in-flight one.
@@ -4,6 +4,56 @@ 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
+
32
+ ## [0.4.0] - 2026-10-03
33
+
34
+ ### Added
35
+
36
+ - Optional HTTP control-plane server (`server` extra): `agentflow.server.
37
+ create_app(queue)` returns a FastAPI ASGI app that maps 1:1 onto a
38
+ `RunQueue`, so runs can be enqueued and observed over the network. Routes:
39
+ `POST /runs`, `GET /runs/{id}`, `GET /runs`, `POST /runs/{id}/resume`,
40
+ `POST /runs/{id}/cancel`, `GET /stats`, `GET /healthz`. Request/response
41
+ bodies are Pydantic models, so FastAPI serves interactive OpenAPI docs at
42
+ `/docs`. `pool_health=` wires a `WorkerPool`'s health into `/healthz`. The
43
+ app adds no behavior of its own — each route forwards to one queue method.
44
+ See `examples/serve_control_plane.py` and the control-plane docs.
45
+ - `server` install extra: `pip install 'agent-workflow-sdk[server]'`
46
+ (`fastapi>=0.110`). The core remains dependency-free; serve the returned
47
+ ASGI app with any ASGI server (e.g. `uvicorn`), installed separately.
48
+
49
+ ### Changed
50
+
51
+ - The worker protocol (`claim` / `heartbeat` / `complete`) is deliberately not
52
+ exposed over HTTP; workers talk to the queue directly. The HTTP layer ships
53
+ no authentication, authorization, rate limiting, or multi-tenancy — those
54
+ stay out of scope and are the caller's responsibility (trust boundary /
55
+ reverse proxy). `DESIGN.md` updated accordingly.
56
+
7
57
  ## [0.3.0] - 2026-10-02
8
58
 
9
59
  ### Added
@@ -152,9 +152,11 @@ execution state; a store is cross-thread application data.
152
152
 
153
153
  ## Control plane
154
154
 
155
- The control plane manages runs from outside the process that created them. It
156
- is a library layer, not a service — there is no HTTP server (that is planned as
157
- a thin adapter over these methods).
155
+ The control plane manages runs from outside the process that created them. The
156
+ core is a library layer, not a service. An optional HTTP layer — the `server`
157
+ extra, `agentflow.server.create_app` — is a thin FastAPI adapter mapping 1:1
158
+ over the queue methods below; it adds no behavior of its own, and auth and
159
+ multi-tenancy remain out of scope (see below).
158
160
 
159
161
  - `GraphRegistry` maps a graph name to a compiled-graph factory. Graphs are
160
162
  code and cannot be serialized, so a run request references a graph by name;
@@ -172,6 +174,13 @@ a thin adapter over these methods).
172
174
  concurrency.
173
175
  - Monitoring: `RunQueue.stats()` and `WorkerPool.health()` return plain values
174
176
  a caller can expose through its own `/healthz` and `/metrics`.
177
+ - HTTP (optional, `server` extra): `agentflow.server.create_app(queue)` returns
178
+ a FastAPI ASGI app exposing the enqueue-and-observe plane — `POST /runs`,
179
+ `GET /runs/{id}`, `GET /runs`, `POST /runs/{id}/resume`,
180
+ `POST /runs/{id}/cancel`, `GET /stats`, `GET /healthz`. The worker protocol
181
+ (`claim` / `heartbeat` / `complete`) is deliberately not exposed; workers
182
+ talk to the queue directly. Each route forwards to one queue method, so the
183
+ HTTP layer carries no state of its own.
175
184
 
176
185
  ## Observability
177
186
 
@@ -197,10 +206,11 @@ See `SECURITY.md` for the reporting process and the operator-facing summary.
197
206
 
198
207
  ## What is intentionally out of scope
199
208
 
200
- - An HTTP/gRPC API over the control plane, and the auth/multi-tenancy that a
201
- service layer implies. These are properties of a service, not this SDK, and
202
- are deferred to a future `server` layer that adapts the existing queue
203
- methods.
209
+ - Authentication, authorization, and multi-tenancy over the HTTP layer. The
210
+ optional `server` extra adapts the queue to HTTP, but it ships no auth and no
211
+ per-tenant isolation — those are properties of a service, not this SDK. Run
212
+ it inside your own trust boundary, or behind a reverse proxy that supplies
213
+ them. A gRPC API is not provided.
204
214
  - Rate limiting and per-tenant quotas. `max_concurrency` /
205
215
  `max_node_concurrency` bound pressure within one process; per-tenant
206
216
  isolation is the caller's responsibility until the service layer exists.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-workflow-sdk
3
- Version: 0.3.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
@@ -20,6 +20,8 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.11
21
21
  Provides-Extra: dev
22
22
  Requires-Dist: fakeredis>=2.20; extra == 'dev'
23
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
24
+ Requires-Dist: httpx>=0.27; extra == 'dev'
23
25
  Requires-Dist: mypy>=1.11; extra == 'dev'
24
26
  Requires-Dist: pip-audit>=2.7; extra == 'dev'
25
27
  Requires-Dist: prometheus-client>=0.20; extra == 'dev'
@@ -41,6 +43,8 @@ Provides-Extra: prometheus
41
43
  Requires-Dist: prometheus-client>=0.20; extra == 'prometheus'
42
44
  Provides-Extra: redis
43
45
  Requires-Dist: redis>=5; extra == 'redis'
46
+ Provides-Extra: server
47
+ Requires-Dist: fastapi>=0.110; extra == 'server'
44
48
  Description-Content-Type: text/markdown
45
49
 
46
50
  # agent-workflow-sdk
@@ -184,7 +188,8 @@ shows the iterate-until-converged loop.
184
188
  State is a `TypedDict` subclass of `State`. Each field is a channel; annotate
185
189
  it with a reducer that folds each node's update into the current value. A
186
190
  field without a reducer uses `last` (last-value-wins). Built-in reducers:
187
- `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
188
193
  never mutates the state it was given.
189
194
 
190
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.3.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",
@@ -0,0 +1,46 @@
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
+ """An HTTP adapter over the control-plane run queue (optional ``server`` extra).
16
+
17
+ This is the thin service layer DESIGN.md defers to: a FastAPI app that maps 1:1
18
+ onto :class:`~agentflow.controlplane.queue.RunQueue` methods, so a run can be
19
+ enqueued, inspected, resumed, or cancelled over HTTP from anywhere. It adds no
20
+ behavior the queue does not already have.
21
+
22
+ from agentflow import PostgresRunQueue
23
+ from agentflow.server import create_app
24
+
25
+ app = create_app(PostgresRunQueue(dsn)) # an ASGI app; serve with uvicorn
26
+
27
+ What it exposes is the *enqueue and observe* plane: ``POST /runs``, ``GET
28
+ /runs/{id}``, ``GET /runs``, ``POST /runs/{id}/resume``, ``POST
29
+ /runs/{id}/cancel``, ``GET /stats``, ``GET /healthz``. The worker protocol
30
+ (``claim`` / ``heartbeat`` / ``complete``) is deliberately *not* exposed —
31
+ those mutate run state and belong to workers, which talk to the queue directly
32
+ (in-process or via the same Postgres queue), not over this API.
33
+
34
+ Scope, stated plainly: there is no authentication, authorization, rate
35
+ limiting, or multi-tenancy here — DESIGN.md defers those as properties of a
36
+ service. Run this inside your own trust boundary, or behind a reverse proxy
37
+ that supplies them. It is an adapter, not a hardened public endpoint.
38
+
39
+ Requires the ``server`` extra: ``pip install 'agent-workflow-sdk[server]'``.
40
+ Serve the returned ASGI app with any ASGI server (e.g. ``uvicorn``), which you
41
+ install separately.
42
+ """
43
+
44
+ from agentflow.server.app import create_app
45
+
46
+ __all__ = ["create_app"]
@@ -0,0 +1,205 @@
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
+ """FastAPI app factory mapping HTTP routes onto a ``RunQueue``.
16
+
17
+ Each route is a direct adapter over one queue method; see the package docstring
18
+ for the exposed surface and the explicit non-goals (no auth, no worker
19
+ protocol). Response/request shapes are Pydantic models so the generated OpenAPI
20
+ docs (``/docs``) are accurate.
21
+
22
+ Note: this module intentionally does NOT use ``from __future__ import
23
+ annotations``. FastAPI resolves endpoint annotations with ``get_type_hints``,
24
+ which cannot re-evaluate a stringized ``Depends(get_queue)`` that references a
25
+ function-local dependency — real annotation objects are required.
26
+ """
27
+
28
+ from collections.abc import Callable
29
+ from typing import Annotated, Any
30
+
31
+ try:
32
+ from fastapi import Depends, FastAPI, HTTPException, Query
33
+ from pydantic import BaseModel, Field
34
+ except ModuleNotFoundError as exc: # pragma: no cover - import-time guard
35
+ raise ModuleNotFoundError(
36
+ "agentflow.server requires FastAPI. Install the extra: "
37
+ "pip install 'agent-workflow-sdk[server]'"
38
+ ) from exc
39
+
40
+ from agentflow.controlplane.queue import RunQueue
41
+ from agentflow.controlplane.records import QueueStats, RunRecord
42
+ from agentflow.errors import RunNotFound
43
+
44
+ __all__ = ["create_app"]
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Wire models (what crosses the HTTP boundary)
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class EnqueueRequest(BaseModel):
53
+ """Body for ``POST /runs`` — ask to run a registered graph."""
54
+
55
+ graph: str = Field(..., description="Name of a graph registered on the worker side.")
56
+ input: dict[str, Any] = Field(default_factory=dict, description="Initial state for the run.")
57
+ thread: str | None = Field(None, description="Checkpoint thread; defaults to the run id.")
58
+
59
+
60
+ class ResumeRequest(BaseModel):
61
+ """Body for ``POST /runs/{run_id}/resume`` — answer an interrupted run."""
62
+
63
+ value: Any = Field(None, description="Value fed to the interrupted node on resume.")
64
+
65
+
66
+ class RunResponse(BaseModel):
67
+ """One run's queue record."""
68
+
69
+ run_id: str
70
+ graph: str
71
+ thread: str
72
+ status: str
73
+ input: dict[str, Any] = Field(default_factory=dict)
74
+ resume_value: Any = None
75
+ error: str | None = None
76
+ attempt: int = 0
77
+ created_at: str = ""
78
+ updated_at: str = ""
79
+ lease_until: str | None = None
80
+ wake_at: str | None = None
81
+ cancel_requested: bool = False
82
+
83
+ @classmethod
84
+ def of(cls, rec: RunRecord) -> "RunResponse":
85
+ return cls(
86
+ run_id=rec.run_id,
87
+ graph=rec.graph,
88
+ thread=rec.thread,
89
+ status=rec.status,
90
+ input=dict(rec.input),
91
+ resume_value=rec.resume_value,
92
+ error=rec.error,
93
+ attempt=rec.attempt,
94
+ created_at=rec.created_at,
95
+ updated_at=rec.updated_at,
96
+ lease_until=rec.lease_until,
97
+ wake_at=rec.wake_at,
98
+ cancel_requested=rec.cancel_requested,
99
+ )
100
+
101
+
102
+ class CancelResponse(BaseModel):
103
+ cancelled: bool = Field(
104
+ ..., description="True if the run existed and was not already terminal."
105
+ )
106
+
107
+
108
+ class StatsResponse(BaseModel):
109
+ total: int = 0
110
+ by_status: dict[str, int] = Field(default_factory=dict)
111
+ expired_leases: int = 0
112
+
113
+ @classmethod
114
+ def of(cls, s: QueueStats) -> "StatsResponse":
115
+ return cls(total=s.total, by_status=dict(s.by_status), expired_leases=s.expired_leases)
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # App factory
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def create_app(
124
+ queue: RunQueue,
125
+ *,
126
+ pool_health: Callable[[], Any] | None = None,
127
+ title: str = "AgentFlow control plane",
128
+ ) -> FastAPI:
129
+ """Build a FastAPI app over ``queue``.
130
+
131
+ ``queue`` is any :class:`~agentflow.controlplane.queue.RunQueue` (Memory for
132
+ a single process, Postgres for a shared/durable one). ``pool_health``, if
133
+ given, is a zero-arg callable returning a
134
+ :class:`~agentflow.controlplane.records.PoolHealth` (typically
135
+ ``pool.health``); ``GET /healthz`` reports it when present. The app adds no
136
+ behavior of its own — every route forwards to a queue method.
137
+ """
138
+ app = FastAPI(title=title, version="1")
139
+
140
+ def get_queue() -> RunQueue:
141
+ return queue
142
+
143
+ # FastAPI dependency injected via the Annotated form (the modern idiom:
144
+ # keeps Depends() out of the parameter default, so no ruff B008). Inlined in
145
+ # each signature rather than aliased to a local var, because FastAPI
146
+ # resolves annotations by name and would not see a function-local alias.
147
+ @app.post("/runs", response_model=RunResponse, status_code=201, tags=["runs"])
148
+ async def enqueue_run(
149
+ body: EnqueueRequest, q: Annotated[RunQueue, Depends(get_queue)]
150
+ ) -> RunResponse:
151
+ rec = await q.enqueue(body.graph, body.input, thread=body.thread)
152
+ return RunResponse.of(rec)
153
+
154
+ @app.get("/runs/{run_id}", response_model=RunResponse, tags=["runs"])
155
+ async def get_run(run_id: str, q: Annotated[RunQueue, Depends(get_queue)]) -> RunResponse:
156
+ rec = await q.get(run_id)
157
+ if rec is None:
158
+ raise HTTPException(status_code=404, detail=f"no run with id {run_id!r}")
159
+ return RunResponse.of(rec)
160
+
161
+ @app.get("/runs", response_model=list[RunResponse], tags=["runs"])
162
+ async def list_runs(
163
+ q: Annotated[RunQueue, Depends(get_queue)],
164
+ status: Annotated[str | None, Query()] = None,
165
+ graph: Annotated[str | None, Query()] = None,
166
+ limit: Annotated[int, Query(ge=1, le=1000)] = 100,
167
+ offset: Annotated[int, Query(ge=0)] = 0,
168
+ ) -> list[RunResponse]:
169
+ recs = await q.list(status=status, graph=graph, limit=limit, offset=offset)
170
+ return [RunResponse.of(r) for r in recs]
171
+
172
+ @app.post("/runs/{run_id}/resume", response_model=RunResponse, tags=["runs"])
173
+ async def resume_run(
174
+ run_id: str, body: ResumeRequest, q: Annotated[RunQueue, Depends(get_queue)]
175
+ ) -> RunResponse:
176
+ try:
177
+ rec = await q.enqueue_resume(run_id, body.value)
178
+ except RunNotFound:
179
+ raise HTTPException(status_code=404, detail=f"no run with id {run_id!r}") from None
180
+ return RunResponse.of(rec)
181
+
182
+ @app.post("/runs/{run_id}/cancel", response_model=CancelResponse, tags=["runs"])
183
+ async def cancel_run(run_id: str, q: Annotated[RunQueue, Depends(get_queue)]) -> CancelResponse:
184
+ cancelled = await q.request_cancel(run_id)
185
+ return CancelResponse(cancelled=cancelled)
186
+
187
+ @app.get("/stats", response_model=StatsResponse, tags=["monitoring"])
188
+ async def get_stats(q: Annotated[RunQueue, Depends(get_queue)]) -> StatsResponse:
189
+ return StatsResponse.of(await q.stats())
190
+
191
+ @app.get("/healthz", tags=["monitoring"])
192
+ async def healthz() -> dict[str, Any]:
193
+ body: dict[str, Any] = {"status": "ok"}
194
+ if pool_health is not None:
195
+ h = pool_health()
196
+ body["pool"] = {
197
+ "workers": h.workers,
198
+ "alive": h.alive,
199
+ "busy": h.busy,
200
+ "idle": h.idle,
201
+ "healthy": h.healthy,
202
+ }
203
+ return body
204
+
205
+ return app
@@ -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:
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## What it is, and is not
6
6
 
7
- The control plane is a library layer, not a service. There is no bundled HTTP server, auth, or tenancy — those are properties of a service you build on top, and are deliberately out of scope. What you get is the durable machinery: a queue, a registry, and workers, all as plain objects you drive.
7
+ The control plane is a library layer first. What you get is the durable machinery: a queue, a registry, and workers, all as plain objects you drive. An optional HTTP layer (the `server` extra) wraps the queue so you can drive it over the network — covered at the end of this chapter. There is still no auth or tenancy anywhere in it; those are properties of a service you put in front, and stay out of scope.
8
8
 
9
9
  ## The pieces
10
10
 
@@ -105,6 +105,67 @@ if not health.healthy:
105
105
 
106
106
  A rising `expired_leases` is the signal that workers are dying or falling behind their heartbeat. `health.healthy` is False when any configured worker loop is not alive — a readiness signal you can expose directly.
107
107
 
108
+ ## Over HTTP
109
+
110
+ Everything so far drives the queue from Python. The optional `server` extra wraps the same queue in a FastAPI app, so you can enqueue and observe runs over the network — from a different language, a web UI, or a `curl` in a shell.
111
+
112
+ ```bash
113
+ pip install 'agent-workflow-sdk[server]'
114
+ ```
115
+
116
+ `create_app` takes a `RunQueue` and returns an ASGI app. It adds no behavior of its own; each route forwards to one queue method.
117
+
118
+ ```python
119
+ from agentflow import PostgresRunQueue
120
+ from agentflow.server import create_app
121
+
122
+ queue = PostgresRunQueue(dsn)
123
+ app = create_app(queue) # an ASGI app
124
+ ```
125
+
126
+ Serve it with any ASGI server. `uvicorn` is a common choice and you install it yourself:
127
+
128
+ ```bash
129
+ uvicorn myapp:app --port 8000
130
+ ```
131
+
132
+ The routes map 1:1 onto the queue methods from earlier in this chapter:
133
+
134
+ - `POST /runs` enqueues a run. Body: `{"graph": "...", "input": {...}, "thread": "..."}` (thread optional). Returns `201` with the run record.
135
+ - `GET /runs/{id}` returns one run record, or `404` if there is no such run.
136
+ - `GET /runs` lists runs. Query params: `status`, `graph`, `limit` (1–1000), `offset`.
137
+ - `POST /runs/{id}/resume` re-queues an interrupted run. Body: `{"value": ...}`. `404` if there is no such run.
138
+ - `POST /runs/{id}/cancel` requests cancellation. Returns `{"cancelled": true}` unless the run was unknown or already terminal.
139
+ - `GET /stats` returns the `QueueStats` snapshot (`total`, `by_status`, `expired_leases`).
140
+ - `GET /healthz` returns `{"status": "ok"}`, plus a `pool` block when you pass `pool_health`.
141
+
142
+ Because the models are typed, FastAPI serves interactive OpenAPI docs at `/docs` and the raw schema at `/openapi.json` with no extra work.
143
+
144
+ A quick enqueue over HTTP:
145
+
146
+ ```bash
147
+ curl -X POST localhost:8000/runs \
148
+ -H 'content-type: application/json' \
149
+ -d '{"graph": "summarize", "input": {"text": "..."}}'
150
+ ```
151
+
152
+ Wire the pool's health into `/healthz` by passing the callable:
153
+
154
+ ```python
155
+ pool = WorkerPool(queue, registry, concurrency=4)
156
+ app = create_app(queue, pool_health=lambda: pool.health())
157
+ ```
158
+
159
+ ### What it does not expose
160
+
161
+ The worker protocol — `claim`, `heartbeat`, `complete` — is deliberately absent from the HTTP surface. Those methods mutate run state and belong to workers, which talk to the queue directly (in-process, or via the same `PostgresRunQueue`), not over this API. The HTTP layer is the enqueue-and-observe plane only.
162
+
163
+ ### Trust boundary
164
+
165
+ There is no authentication, authorization, rate limiting, or multi-tenancy in this app. It is an adapter over the queue, not a hardened public endpoint. Run it inside your own trust boundary, or behind a reverse proxy or gateway that supplies those. Anyone who can reach it can enqueue, cancel, and resume any run.
166
+
167
+ See the [HTTP server API reference](../reference/api/server.md) for the request and response models.
168
+
108
169
  ## When to use the control plane
109
170
 
110
171
  - Use inline `invoke`/`stream` when the run belongs to the calling process — a request handler, a script, a test.