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.
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/CHANGELOG.md +25 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/PKG-INFO +3 -2
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/README.md +2 -1
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/__init__.py +3 -1
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/state.py +60 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/state.md +29 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/long-running-loops.md +22 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/state.md +1 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api.md +1 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/glossary.md +1 -1
- agent_workflow_sdk-0.5.0/examples/dual_state_search.py +188 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/pyproject.toml +1 -1
- agent_workflow_sdk-0.5.0/tests/test_reducers.py +168 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.env.example +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/ci.yml +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/docs.yml +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/workflows/release.yml +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.gitignore +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/CONTRIBUTING.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/DESIGN.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/LICENSE +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/NOTICE +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/SECURITY.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/_pg.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/_http.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/anthropic.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/base.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/claude_code.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/cli_exec.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/codex.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/kiro.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/ollama.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/backends/openai.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/_serde.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/base.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/file.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/memory.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/postgres.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/redis.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/checkpoint/sqlite.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/compiled.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/memory.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/postgres.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/queue.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/records.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/registry.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/controlplane/worker.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/errors.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/events.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/graph.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/interrupts.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/observability.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/otel.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/effects.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/gate.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/idempotent.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/loop.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/resilience.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/tool_loop.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prebuilt/watch.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/prometheus.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/py.typed +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/redaction.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/runtime.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/server/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/server/app.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/__init__.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/_util.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/base.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/memory.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/store/postgres.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/agentflow/telemetry.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/agent-backends.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/llm-backends.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/overview.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/permissions.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/backends/retries.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/context.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/execution.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/nodes-and-edges.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/running.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/concepts/subgraphs.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/checkpointing.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/control-plane.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/human-in-the-loop.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/observability.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/quality-gates.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/durability/store.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/installation.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/mental-model.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/overview.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/get-started/quickstart.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/index.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/prebuilt.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/patterns/tutorial-qa-agent.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/backends.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/checkpoint.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/compiled.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/controlplane.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/errors.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/events.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/gate.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/graph.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/interrupts.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/observability.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/permissions.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/prebuilt.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/runtime.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/server.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/store.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/docs/reference/api/watch.md +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/agents_demo.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/codebase_qa_ollama.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/hello_graph.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/hitl_permission.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/idempotent_effect.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/mixed_backends.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/optimize_loop.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/quality_gate.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/research_plan_implement_review.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/serve_control_plane.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/telemetry_demo.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/tool_loop_ollama.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/examples/watch_until_done.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/mkdocs.yml +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/conftest.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_anthropic_backend.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_backend_lifecycle.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_checkpoint_contract.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_cli_backends.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_concurrency.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_controlplane.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_effects.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_gate.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_graph_engine.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_hitl_permission.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_http_retry.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_idempotent.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_input_validation.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_kiro_backend.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_live_backends.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_observability.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_ollama_backend.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_openai_backend.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_otel.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_permission_policy.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_checkpointer.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_runqueue.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_postgres_store.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_loop.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prebuilt_no_httpx.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_prometheus.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_provider_resilience.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_redaction.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_redis_checkpointer.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_resilience.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_run_timeout.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_server.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_sqlite_checkpointer.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_state_isolation.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_step_atomicity.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_store.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_subgraph.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_sync_facade.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_telemetry.py +0 -0
- {agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/tests/test_tool_loop.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
@@ -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.
|
|
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"] == []
|
|
File without changes
|
|
File without changes
|
{agent_workflow_sdk-0.4.0 → agent_workflow_sdk-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|