agent-workflow-sdk 0.1.0__tar.gz → 0.2.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 (155) hide show
  1. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.gitignore +3 -0
  2. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/CHANGELOG.md +94 -0
  3. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/PKG-INFO +17 -11
  4. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/README.md +13 -10
  5. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/__init__.py +16 -1
  6. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/_http.py +6 -3
  7. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/base.py +78 -1
  8. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/claude_code.py +19 -5
  9. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/codex.py +22 -4
  10. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/kiro.py +69 -5
  11. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/errors.py +18 -0
  12. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/events.py +25 -0
  13. agent_workflow_sdk-0.2.0/agentflow/interrupts.py +113 -0
  14. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/__init__.py +12 -0
  15. agent_workflow_sdk-0.2.0/agentflow/prebuilt/effects.py +319 -0
  16. agent_workflow_sdk-0.2.0/agentflow/prebuilt/gate.py +196 -0
  17. agent_workflow_sdk-0.2.0/agentflow/prebuilt/idempotent.py +105 -0
  18. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/runtime.py +7 -3
  19. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/base.py +11 -0
  20. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/memory.py +9 -1
  21. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/postgres.py +29 -0
  22. agent_workflow_sdk-0.2.0/docs/backends/agent-backends.md +102 -0
  23. agent_workflow_sdk-0.2.0/docs/backends/llm-backends.md +106 -0
  24. agent_workflow_sdk-0.2.0/docs/backends/overview.md +81 -0
  25. agent_workflow_sdk-0.2.0/docs/backends/permissions.md +116 -0
  26. agent_workflow_sdk-0.2.0/docs/backends/retries.md +75 -0
  27. agent_workflow_sdk-0.2.0/docs/concepts/context.md +50 -0
  28. agent_workflow_sdk-0.2.0/docs/concepts/execution.md +52 -0
  29. agent_workflow_sdk-0.2.0/docs/concepts/nodes-and-edges.md +66 -0
  30. agent_workflow_sdk-0.2.0/docs/concepts/running.md +72 -0
  31. agent_workflow_sdk-0.2.0/docs/concepts/state.md +57 -0
  32. agent_workflow_sdk-0.2.0/docs/concepts/subgraphs.md +49 -0
  33. agent_workflow_sdk-0.2.0/docs/durability/checkpointing.md +103 -0
  34. agent_workflow_sdk-0.2.0/docs/durability/control-plane.md +113 -0
  35. agent_workflow_sdk-0.2.0/docs/durability/human-in-the-loop.md +117 -0
  36. agent_workflow_sdk-0.2.0/docs/durability/observability.md +108 -0
  37. agent_workflow_sdk-0.2.0/docs/durability/quality-gates.md +122 -0
  38. agent_workflow_sdk-0.2.0/docs/durability/store.md +117 -0
  39. agent_workflow_sdk-0.2.0/docs/get-started/installation.md +41 -0
  40. agent_workflow_sdk-0.2.0/docs/get-started/mental-model.md +36 -0
  41. agent_workflow_sdk-0.2.0/docs/get-started/overview.md +21 -0
  42. agent_workflow_sdk-0.2.0/docs/get-started/quickstart.md +132 -0
  43. agent_workflow_sdk-0.2.0/docs/index.md +57 -0
  44. agent_workflow_sdk-0.2.0/docs/patterns/long-running-loops.md +182 -0
  45. agent_workflow_sdk-0.2.0/docs/patterns/prebuilt.md +110 -0
  46. agent_workflow_sdk-0.2.0/docs/patterns/tutorial-qa-agent.md +139 -0
  47. agent_workflow_sdk-0.2.0/docs/reference/api/backends.md +26 -0
  48. agent_workflow_sdk-0.2.0/docs/reference/api/checkpoint.md +15 -0
  49. agent_workflow_sdk-0.2.0/docs/reference/api/compiled.md +5 -0
  50. agent_workflow_sdk-0.2.0/docs/reference/api/controlplane.md +17 -0
  51. agent_workflow_sdk-0.2.0/docs/reference/api/errors.md +26 -0
  52. agent_workflow_sdk-0.2.0/docs/reference/api/events.md +22 -0
  53. agent_workflow_sdk-0.2.0/docs/reference/api/gate.md +28 -0
  54. agent_workflow_sdk-0.2.0/docs/reference/api/graph.md +13 -0
  55. agent_workflow_sdk-0.2.0/docs/reference/api/interrupts.md +10 -0
  56. agent_workflow_sdk-0.2.0/docs/reference/api/observability.md +16 -0
  57. agent_workflow_sdk-0.2.0/docs/reference/api/permissions.md +13 -0
  58. agent_workflow_sdk-0.2.0/docs/reference/api/prebuilt.md +14 -0
  59. agent_workflow_sdk-0.2.0/docs/reference/api/runtime.md +7 -0
  60. agent_workflow_sdk-0.2.0/docs/reference/api/state.md +15 -0
  61. agent_workflow_sdk-0.2.0/docs/reference/api/store.md +11 -0
  62. agent_workflow_sdk-0.2.0/docs/reference/api.md +210 -0
  63. agent_workflow_sdk-0.2.0/docs/reference/glossary.md +123 -0
  64. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/agents_demo.py +6 -7
  65. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/hello_graph.py +4 -2
  66. agent_workflow_sdk-0.2.0/examples/hitl_permission.py +125 -0
  67. agent_workflow_sdk-0.2.0/examples/idempotent_effect.py +100 -0
  68. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/optimize_loop.py +5 -0
  69. agent_workflow_sdk-0.2.0/examples/quality_gate.py +125 -0
  70. agent_workflow_sdk-0.2.0/examples/research_plan_implement_review.py +154 -0
  71. agent_workflow_sdk-0.2.0/mkdocs.yml +151 -0
  72. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/pyproject.toml +2 -1
  73. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_cli_backends.py +58 -0
  74. agent_workflow_sdk-0.2.0/tests/test_effects.py +342 -0
  75. agent_workflow_sdk-0.2.0/tests/test_gate.py +204 -0
  76. agent_workflow_sdk-0.2.0/tests/test_idempotent.py +144 -0
  77. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_kiro_backend.py +26 -0
  78. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_permission_policy.py +36 -0
  79. agent_workflow_sdk-0.2.0/tests/test_prebuilt_no_httpx.py +64 -0
  80. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_store.py +48 -0
  81. agent_workflow_sdk-0.1.0/examples/hitl_permission.py +0 -107
  82. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.env.example +0 -0
  83. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.github/workflows/ci.yml +0 -0
  84. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/CONTRIBUTING.md +0 -0
  85. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/DESIGN.md +0 -0
  86. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/LICENSE +0 -0
  87. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/NOTICE +0 -0
  88. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/SECURITY.md +0 -0
  89. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/__init__.py +0 -0
  90. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/anthropic.py +0 -0
  91. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/cli_exec.py +0 -0
  92. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/ollama.py +0 -0
  93. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/openai.py +0 -0
  94. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/__init__.py +0 -0
  95. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/_serde.py +0 -0
  96. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/base.py +0 -0
  97. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/file.py +0 -0
  98. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/memory.py +0 -0
  99. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/postgres.py +0 -0
  100. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/redis.py +0 -0
  101. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/sqlite.py +0 -0
  102. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/compiled.py +0 -0
  103. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/__init__.py +0 -0
  104. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/memory.py +0 -0
  105. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/postgres.py +0 -0
  106. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/queue.py +0 -0
  107. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/records.py +0 -0
  108. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/registry.py +0 -0
  109. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/worker.py +0 -0
  110. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/graph.py +0 -0
  111. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/observability.py +0 -0
  112. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/otel.py +0 -0
  113. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/loop.py +0 -0
  114. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/resilience.py +0 -0
  115. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/tool_loop.py +0 -0
  116. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prometheus.py +0 -0
  117. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/py.typed +0 -0
  118. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/redaction.py +0 -0
  119. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/state.py +0 -0
  120. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/__init__.py +0 -0
  121. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/_util.py +0 -0
  122. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/telemetry.py +0 -0
  123. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/codebase_qa_ollama.py +0 -0
  124. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/mixed_backends.py +0 -0
  125. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/telemetry_demo.py +0 -0
  126. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/tool_loop_ollama.py +0 -0
  127. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/conftest.py +0 -0
  128. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_anthropic_backend.py +0 -0
  129. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_backend_lifecycle.py +0 -0
  130. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_checkpoint_contract.py +0 -0
  131. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_concurrency.py +0 -0
  132. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_controlplane.py +0 -0
  133. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_graph_engine.py +0 -0
  134. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_hitl_permission.py +0 -0
  135. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_http_retry.py +0 -0
  136. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_input_validation.py +0 -0
  137. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_live_backends.py +0 -0
  138. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_observability.py +0 -0
  139. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_ollama_backend.py +0 -0
  140. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_openai_backend.py +0 -0
  141. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_otel.py +0 -0
  142. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_prebuilt_loop.py +0 -0
  143. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_prometheus.py +0 -0
  144. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_provider_resilience.py +0 -0
  145. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_redaction.py +0 -0
  146. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_redis_checkpointer.py +0 -0
  147. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_resilience.py +0 -0
  148. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_run_timeout.py +0 -0
  149. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_sqlite_checkpointer.py +0 -0
  150. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_state_isolation.py +0 -0
  151. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_step_atomicity.py +0 -0
  152. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_subgraph.py +0 -0
  153. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_sync_facade.py +0 -0
  154. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_telemetry.py +0 -0
  155. {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_tool_loop.py +0 -0
@@ -11,3 +11,6 @@ build/
11
11
  .coverage
12
12
  coverage.xml
13
13
  .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ site/
@@ -4,6 +4,99 @@ 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.2.0] - 2026-10-01
8
+
9
+ ### Added
10
+
11
+ - Quality gates (`agentflow.prebuilt`): `add_quality_gate(g, name, evaluator,
12
+ *, on_pass, on_fail, on_escalate, max_attempts=3)` adds a checked
13
+ accept/revise/escalate checkpoint built from a conditional edge and a
14
+ per-gate attempt counter. An evaluator returns a `GateResult(passed, score,
15
+ reasons, feedback)`; mix `GateState` into a schema for the `gate_attempts`
16
+ and `gate_results` channels. All three routes are required so a gate never
17
+ silently ships a failing artifact once attempts run out.
18
+ - `GateEvent` (`agentflow.events`): a gate emits `started` / `passed` /
19
+ `failed` / `escalated` via `ctx.emit`, so `RunMetrics`, `JsonlTelemetry`, and
20
+ the run stream show every gate decision. `Context.emit` now accepts a
21
+ backend event or a `GateEvent`.
22
+ - Idempotency helpers (`agentflow.prebuilt`): `skip_if_done(node, store, *,
23
+ namespace, key_from, ttl=None)` runs a node once per distinct key and serves
24
+ the cached update from a `Store` thereafter; `artifact_key(value)` is a
25
+ stable content hash to key it by.
26
+ - Structured interrupt-payload convention (`agentflow.interrupts`):
27
+ `QualityGateReview` and `ClarificationRequest` (each with `.to_payload()`
28
+ carrying a `type` discriminator) plus `interrupt_type(payload)`, generalizing
29
+ the `PermissionRequest` shape so a UI or control plane can render any pause.
30
+ - Docs: "Quality gates" and "Long-running loops" chapters, a generated API
31
+ reference (mkdocstrings), and the `examples/quality_gate.py` and
32
+ `examples/research_plan_implement_review.py` examples.
33
+
34
+ - Atomic conditional write on `Store`: `put(..., if_absent=True)` creates only
35
+ if no live item exists at the key, else raises the new `StoreConflict` (an
36
+ expired item counts as absent and can be reclaimed). Implemented for
37
+ `MemoryStore` (atomic within a process) and `PostgresStore` (atomic across
38
+ processes via a single conditional `INSERT`). This is the store-level
39
+ primitive for a single-flight claim.
40
+ - Effect-level idempotency (`agentflow.prebuilt`): `IdempotentOp(store,
41
+ namespace)` protects one side effect inside a node with an atomic claim → run
42
+ → commit in a `Store`, so a resume or retry does not repeat it, and two
43
+ workers racing the same key give one winner and one conflict (the
44
+ duplicate-starter race is closed within a backend). Its `on_incomplete` policy
45
+ (`"error"` default, `"rerun"`, `"skip"`) handles a prior attempt left
46
+ `in_flight`, and `on_error` (`"keep"` default, `"release"`) controls the
47
+ marker when `fn` raises — because an exception does not prove the effect was
48
+ skipped (a lost response after a successful `POST`). `effect_key(*parts)`
49
+ builds a stable token to pass downstream as the provider's idempotency key.
50
+ New `IncompleteEffectError`. This narrows the duplicate window and makes every
51
+ ambiguous case explicit, but does not by itself guarantee exactly-once at the
52
+ orchestrator layer — the winner can still crash or lose its response inside
53
+ the effect, so exactly-once ultimately needs idempotency at the effect.
54
+ Checkpoints alone give only at-least-once, since a super-step is atomic over
55
+ state only.
56
+
57
+ ### Fixed
58
+
59
+ - `import agentflow.prebuilt` no longer requires the `ollama`/`httpx` extra.
60
+ `httpx` is now imported lazily inside `open_stream`, so the `backends`
61
+ package (and `RetryPolicy`) and the prebuilt patterns load dependency-free;
62
+ `httpx` is pulled in only when an HTTP stream is actually opened by a
63
+ concrete LLM backend.
64
+
65
+ ## [0.1.1] - 2026-09-29
66
+
67
+ ### Added
68
+
69
+ - `Callback(async_fn)` permission policy: asks a human (or any resolver) inline
70
+ within the same turn and answers the agent's request immediately, without an
71
+ engine interrupt/resume. This is the correct policy for a persistent-session
72
+ backend like Kiro — `Interactive` suspends the run and resumes a new turn,
73
+ which deadlocks a live ACP session that is holding the original turn open
74
+ awaiting the decision. `Interactive` remains right for one-shot backends.
75
+ - Permission policy is now translated to CLI launch flags for the one-shot
76
+ agents (best-effort upfront enforcement, since they have no per-tool runtime
77
+ gate): `CodexBackend` derives `--sandbox` (`AllowAll` → `workspace-write`,
78
+ `DenyAll`/`ToolAllowlist` → `read-only`); `ClaudeCodeBackend` derives
79
+ `--allowed-tools` from a `ToolAllowlist` and `--permission-mode plan` from
80
+ `DenyAll`. An explicit `sandbox=`/`allowed_tools=`/`permission_mode=` you pass
81
+ always wins over the derived value.
82
+
83
+ ### Changed
84
+
85
+ - `CodexBackend(sandbox=...)` now defaults to `None` (derive from the policy)
86
+ instead of `"read-only"`. With `permission=AllowAll()` this means Codex now
87
+ launches `workspace-write` rather than `read-only`; pass `sandbox="read-only"`
88
+ explicitly to keep the old behavior.
89
+
90
+ ### Fixed
91
+
92
+ - `KiroBackend` now extracts the tool name from the v3 permission request
93
+ (`_meta.kiro.toolId` / `consent.capability` / `toolCall.title`), so a
94
+ `PermissionRequest` no longer arrives with an empty `tool`.
95
+ - `KiroBackend.close()` returns promptly (short per-phase grace, configurable
96
+ via `close_grace`) and signals the child's whole process group, so the v3
97
+ CLI's helper process (`kiro-cli-chat`) is reaped instead of stranding the
98
+ terminal.
99
+
7
100
  ## [0.1.0] - 2026-09-29
8
101
 
9
102
  First public release: an async-first, LangGraph-style SDK for agent workflows
@@ -165,4 +258,5 @@ Core engine (the foundation the above builds on):
165
258
  GitHub Actions CI (offline suite + wheel build), and a `live` pytest marker
166
259
  separating opt-in real-backend smoke tests from the hermetic default run.
167
260
 
261
+ [0.1.1]: https://github.com/dankosorkin/agent-workflow-sdk/compare/v0.1.0...v0.1.1
168
262
  [0.1.0]: https://github.com/dankosorkin/agent-workflow-sdk/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-workflow-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async-first, LangGraph-style SDK for building agent workflows with pluggable agent and LLM backends.
5
5
  Project-URL: Homepage, https://github.com/dankosorkin/agent-workflow-sdk
6
6
  Project-URL: Repository, https://github.com/dankosorkin/agent-workflow-sdk
@@ -27,6 +27,9 @@ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
27
  Requires-Dist: pytest-cov>=5; extra == 'dev'
28
28
  Requires-Dist: pytest>=8; extra == 'dev'
29
29
  Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Provides-Extra: docs
31
+ Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
32
+ Requires-Dist: mkdocstrings[python]>=0.26; extra == 'docs'
30
33
  Provides-Extra: ollama
31
34
  Requires-Dist: httpx>=0.27; extra == 'ollama'
32
35
  Provides-Extra: otel
@@ -284,25 +287,28 @@ machine (set `KIRO_AGENT` to a valid agent id, e.g. `vibe`, to include Kiro).
284
287
  A `PermissionPolicy` is **required** when constructing an agent backend — there
285
288
  is no default, because auto-approving an agent's tool use is a security choice
286
289
  the caller must make explicitly. Options: `AllowAll` (trusted local sandbox
287
- only), `DenyAll`, `Interactive` (escalate to a human via an engine interrupt),
288
- or `ToolAllowlist({"read_file", ...}, fallback=DenyAll())` for least-privilege
289
- scoping.
290
+ only), `DenyAll`, `ToolAllowlist({"read_file", ...}, fallback=DenyAll())` for
291
+ least-privilege scoping, and two ways to ask a human — `Interactive` (suspends
292
+ the run via an engine interrupt; resume with a decision) and `Callback(async
293
+ fn)` (asks inline within the same turn, no interrupt — the right choice for a
294
+ persistent-session backend like Kiro).
290
295
 
291
296
  ```python
292
297
  from agentflow.backends import AllowAll, ToolAllowlist, DenyAll
293
298
  KiroBackend("vibe", permission=ToolAllowlist({"read_file"}, fallback=DenyAll()))
294
299
  ```
295
300
 
296
- Capability matrix — where the policy actually applies:
301
+ Capability matrix — how the policy is applied:
297
302
 
298
- | Backend | Routes tool requests through `PermissionPolicy`? |
303
+ | Backend | How `PermissionPolicy` applies |
299
304
  | --- | --- |
300
- | `KiroBackend` | Yes — ACP `session/request_permission` is resolved by the policy (and `Interactive` drives a real HITL interrupt). |
301
- | `CodexBackend` | No — one-shot CLI; permission is governed by its `--sandbox` mode. The policy field is still required but does not intercept prompts. |
302
- | `ClaudeCodeBackend` | No — one-shot CLI; permission is governed by CLI flags (`--permission-mode`, `--allowed-tools`). |
305
+ | `KiroBackend` | Runtime gate — ACP `session/request_permission` is resolved by the policy per tool call (`Interactive`/`Callback` ask a human; `ToolAllowlist` filters). |
306
+ | `CodexBackend` | Translated to a launch flag — the policy sets Codex's `--sandbox` when you don't set it yourself: `AllowAll` → `workspace-write`, `DenyAll`/`ToolAllowlist` → `read-only`. No per-tool runtime gate. |
307
+ | `ClaudeCodeBackend` | Translated to launch flags — `ToolAllowlist` → `--allowed-tools`, `DenyAll` → `--permission-mode plan` (read-only), when you don't set those yourself. No per-tool runtime gate. |
303
308
 
304
- For the one-shot CLI agents, configure their own controls (sandbox, allowed
305
- tools) — the SDK policy alone does not sandbox them.
309
+ For the one-shot CLI agents the policy is a best-effort *upfront* mapping to
310
+ their own controls, not our runtime interception — an explicit `sandbox=` /
311
+ `allowed_tools=` you pass always wins over the derived value.
306
312
 
307
313
  ## Checkpointing and human-in-the-loop
308
314
 
@@ -242,25 +242,28 @@ machine (set `KIRO_AGENT` to a valid agent id, e.g. `vibe`, to include Kiro).
242
242
  A `PermissionPolicy` is **required** when constructing an agent backend — there
243
243
  is no default, because auto-approving an agent's tool use is a security choice
244
244
  the caller must make explicitly. Options: `AllowAll` (trusted local sandbox
245
- only), `DenyAll`, `Interactive` (escalate to a human via an engine interrupt),
246
- or `ToolAllowlist({"read_file", ...}, fallback=DenyAll())` for least-privilege
247
- scoping.
245
+ only), `DenyAll`, `ToolAllowlist({"read_file", ...}, fallback=DenyAll())` for
246
+ least-privilege scoping, and two ways to ask a human — `Interactive` (suspends
247
+ the run via an engine interrupt; resume with a decision) and `Callback(async
248
+ fn)` (asks inline within the same turn, no interrupt — the right choice for a
249
+ persistent-session backend like Kiro).
248
250
 
249
251
  ```python
250
252
  from agentflow.backends import AllowAll, ToolAllowlist, DenyAll
251
253
  KiroBackend("vibe", permission=ToolAllowlist({"read_file"}, fallback=DenyAll()))
252
254
  ```
253
255
 
254
- Capability matrix — where the policy actually applies:
256
+ Capability matrix — how the policy is applied:
255
257
 
256
- | Backend | Routes tool requests through `PermissionPolicy`? |
258
+ | Backend | How `PermissionPolicy` applies |
257
259
  | --- | --- |
258
- | `KiroBackend` | Yes — ACP `session/request_permission` is resolved by the policy (and `Interactive` drives a real HITL interrupt). |
259
- | `CodexBackend` | No — one-shot CLI; permission is governed by its `--sandbox` mode. The policy field is still required but does not intercept prompts. |
260
- | `ClaudeCodeBackend` | No — one-shot CLI; permission is governed by CLI flags (`--permission-mode`, `--allowed-tools`). |
260
+ | `KiroBackend` | Runtime gate — ACP `session/request_permission` is resolved by the policy per tool call (`Interactive`/`Callback` ask a human; `ToolAllowlist` filters). |
261
+ | `CodexBackend` | Translated to a launch flag — the policy sets Codex's `--sandbox` when you don't set it yourself: `AllowAll` → `workspace-write`, `DenyAll`/`ToolAllowlist` → `read-only`. No per-tool runtime gate. |
262
+ | `ClaudeCodeBackend` | Translated to launch flags — `ToolAllowlist` → `--allowed-tools`, `DenyAll` → `--permission-mode plan` (read-only), when you don't set those yourself. No per-tool runtime gate. |
261
263
 
262
- For the one-shot CLI agents, configure their own controls (sandbox, allowed
263
- tools) — the SDK policy alone does not sandbox them.
264
+ For the one-shot CLI agents the policy is a best-effort *upfront* mapping to
265
+ their own controls, not our runtime interception — an explicit `sandbox=` /
266
+ `allowed_tools=` you pass always wins over the derived value.
264
267
 
265
268
  ## Checkpointing and human-in-the-loop
266
269
 
@@ -55,12 +55,15 @@ from agentflow.errors import (
55
55
  NodeError,
56
56
  RunNotFound,
57
57
  RunTimeout,
58
+ StoreConflict,
59
+ StoreError,
58
60
  )
59
61
  from agentflow.events import (
60
62
  Allow,
61
63
  ChatRequest,
62
64
  Deny,
63
65
  ErrorEvent,
66
+ GateEvent,
64
67
  Message,
65
68
  PermissionOption,
66
69
  PermissionRequest,
@@ -73,6 +76,11 @@ from agentflow.events import (
73
76
  TurnEnd,
74
77
  )
75
78
  from agentflow.graph import END, START, Graph, Node, Router
79
+ from agentflow.interrupts import (
80
+ ClarificationRequest,
81
+ QualityGateReview,
82
+ interrupt_type,
83
+ )
76
84
  from agentflow.observability import Hooks, NodeStat, RunMetrics
77
85
  from agentflow.redaction import RedactKeys, Redactor, redact_none
78
86
  from agentflow.runtime import Context, StreamEvent
@@ -89,7 +97,7 @@ from agentflow.state import (
89
97
  from agentflow.store import Item, MemoryStore, Store
90
98
  from agentflow.telemetry import JsonlTelemetry, MultiHooks
91
99
 
92
- __version__ = "0.1.0"
100
+ __version__ = "0.2.0"
93
101
 
94
102
  __all__ = [
95
103
  "__version__",
@@ -133,8 +141,13 @@ __all__ = [
133
141
  "PermissionOption",
134
142
  "TurnEnd",
135
143
  "ErrorEvent",
144
+ "GateEvent",
136
145
  "Allow",
137
146
  "Deny",
147
+ # human-in-the-loop interrupt payloads
148
+ "QualityGateReview",
149
+ "ClarificationRequest",
150
+ "interrupt_type",
138
151
  # checkpointing
139
152
  "Checkpoint",
140
153
  "Checkpointer",
@@ -171,6 +184,8 @@ __all__ = [
171
184
  "BackendRateLimitError",
172
185
  "CheckpointError",
173
186
  "CheckpointConflict",
187
+ "StoreError",
188
+ "StoreConflict",
174
189
  "ControlPlaneError",
175
190
  "RunNotFound",
176
191
  "InterruptError",
@@ -41,12 +41,13 @@ from collections.abc import AsyncIterator
41
41
  from contextlib import asynccontextmanager
42
42
  from dataclasses import dataclass
43
43
  from datetime import UTC, datetime
44
- from typing import Any
45
-
46
- import httpx
44
+ from typing import TYPE_CHECKING, Any
47
45
 
48
46
  from agentflow.errors import BackendRateLimitError, BackendTransportError
49
47
 
48
+ if TYPE_CHECKING:
49
+ import httpx
50
+
50
51
  __all__ = ["RetryPolicy", "open_stream"]
51
52
 
52
53
  _RETRYABLE_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
@@ -117,6 +118,8 @@ async def open_stream(
117
118
  """
118
119
  pol = policy or RetryPolicy(max_retries=0)
119
120
  # Resolve the sleeper at call time so tests can monkeypatch asyncio.sleep.
121
+ import httpx # local: keep RetryPolicy importable without the http extra
122
+
120
123
  if sleep is None:
121
124
  sleep = asyncio.sleep
122
125
  attempt = 0
@@ -25,7 +25,8 @@ This module imports only :mod:`agentflow.events` and
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- from collections.abc import AsyncIterator
28
+ from collections.abc import AsyncIterator, Awaitable, Callable
29
+ from dataclasses import dataclass
29
30
  from typing import Any, Protocol, runtime_checkable
30
31
 
31
32
  from agentflow.errors import InterruptError
@@ -50,7 +51,10 @@ __all__ = [
50
51
  "AllowAll",
51
52
  "DenyAll",
52
53
  "Interactive",
54
+ "Callback",
53
55
  "ToolAllowlist",
56
+ "PolicyIntent",
57
+ "policy_intent",
54
58
  ]
55
59
 
56
60
 
@@ -311,6 +315,36 @@ class Interactive:
311
315
  return _normalize_decision(answer)
312
316
 
313
317
 
318
+ class Callback:
319
+ """Ask a human (or any resolver) inline, without interrupting the graph.
320
+
321
+ Unlike :class:`Interactive` — which suspends the whole run via
322
+ ``ctx.interrupt`` and requires a separate ``resume()`` — this awaits a
323
+ caller-supplied async function *within the same turn* and answers the
324
+ agent's permission request immediately. That matters for a persistent
325
+ session backend (Kiro's ACP): the agent is holding one turn open waiting
326
+ for the decision, so the answer must go back in that turn rather than
327
+ abandoning it and starting a new one.
328
+
329
+ ``resolver`` is ``async (PermissionRequest) -> decision`` where the
330
+ decision is an :class:`Allow`/:class:`Deny`, or a value normalized the same
331
+ way as a resume value (``"y"``/``"n"``, ``None``, an option id, ...).
332
+
333
+ async def ask(req):
334
+ answer = await asyncio.to_thread(input, f"allow {req.tool}? [y/N] ")
335
+ return answer
336
+
337
+ backend = KiroBackend("vibe", permission=Callback(ask))
338
+ """
339
+
340
+ def __init__(self, resolver: Callable[[PermissionRequest], Awaitable[Any]]) -> None:
341
+ self._resolver = resolver
342
+
343
+ async def decide(self, req: PermissionRequest) -> PermissionDecision:
344
+ answer = await self._resolver(req)
345
+ return _normalize_decision(answer)
346
+
347
+
314
348
  def _normalize_decision(answer: Any) -> PermissionDecision:
315
349
  """Coerce a human's resume value into a PermissionDecision."""
316
350
  if isinstance(answer, (Allow, Deny)):
@@ -325,3 +359,46 @@ def _normalize_decision(answer: Any) -> PermissionDecision:
325
359
  if answer is None or answer is False:
326
360
  return Deny()
327
361
  return Allow()
362
+
363
+
364
+ @dataclass(frozen=True, slots=True)
365
+ class PolicyIntent:
366
+ """A backend-agnostic reading of a permission policy, for CLI backends.
367
+
368
+ The one-shot CLI agents (Codex, Claude Code) do not surface per-tool
369
+ permission requests we can gate at runtime — they enforce permissions via
370
+ their own launch flags. This distils a :class:`PermissionPolicy` into an
371
+ upfront intent those backends can translate to flags:
372
+
373
+ - ``deny_all``: the policy denies everything (``DenyAll``, or an empty
374
+ ``ToolAllowlist`` whose fallback denies) — launch as restrictively as the
375
+ CLI allows.
376
+ - ``allow_all``: the policy approves everything (``AllowAll``).
377
+ - ``allowlist``: the explicit set of permitted tools (``ToolAllowlist``),
378
+ or ``None`` when the policy is not an allowlist.
379
+
380
+ An ``Interactive``/``Callback`` policy has no static answer (it needs a
381
+ live request), so it reads as neither allow_all nor deny_all — the CLI's
382
+ own default applies. This is a best-effort mapping, not our runtime gate;
383
+ the CLI's controls are authoritative.
384
+ """
385
+
386
+ deny_all: bool = False
387
+ allow_all: bool = False
388
+ allowlist: frozenset[str] | None = None
389
+
390
+
391
+ def policy_intent(policy: PermissionPolicy) -> PolicyIntent:
392
+ """Best-effort static reading of ``policy`` for CLI-flag translation."""
393
+ if isinstance(policy, AllowAll):
394
+ return PolicyIntent(allow_all=True)
395
+ if isinstance(policy, DenyAll):
396
+ return PolicyIntent(deny_all=True)
397
+ if isinstance(policy, ToolAllowlist):
398
+ allowed = frozenset(policy.allowed)
399
+ # An empty allowlist with a denying fallback means "deny everything".
400
+ if not allowed and isinstance(policy.fallback, DenyAll):
401
+ return PolicyIntent(deny_all=True, allowlist=allowed)
402
+ return PolicyIntent(allowlist=allowed)
403
+ # Interactive / Callback / custom: no static answer.
404
+ return PolicyIntent()
@@ -35,7 +35,7 @@ from __future__ import annotations
35
35
  from pathlib import Path
36
36
  from typing import Any
37
37
 
38
- from agentflow.backends.base import PermissionPolicy
38
+ from agentflow.backends.base import PermissionPolicy, policy_intent
39
39
  from agentflow.backends.cli_exec import CLIExecBackend, TurnAccumulator
40
40
  from agentflow.events import BackendEvent, TextChunk, ToolCall
41
41
 
@@ -102,12 +102,26 @@ class ClaudeCodeBackend(CLIExecBackend):
102
102
  cmd += ["--model", self.model]
103
103
  if self.agent:
104
104
  cmd += ["--agent", self.agent]
105
- if self.allowed_tools:
106
- cmd += ["--allowed-tools", " ".join(self.allowed_tools)]
105
+
106
+ # Explicit tool flags win; otherwise derive them from the permission
107
+ # policy. Claude Code has no per-tool runtime gate we can hook, so a
108
+ # ToolAllowlist maps to --allowed-tools and a deny-all policy maps to
109
+ # the read-only "plan" permission mode (no edits/commands).
110
+ allowed = list(self.allowed_tools)
111
+ permission_mode = self.permission_mode
112
+ if not allowed and permission_mode is None:
113
+ intent = policy_intent(self.permission)
114
+ if intent.allowlist is not None and intent.allowlist:
115
+ allowed = sorted(intent.allowlist)
116
+ elif intent.deny_all:
117
+ permission_mode = "plan"
118
+
119
+ if allowed:
120
+ cmd += ["--allowed-tools", " ".join(allowed)]
107
121
  if self.disallowed_tools:
108
122
  cmd += ["--disallowed-tools", " ".join(self.disallowed_tools)]
109
- if self.permission_mode:
110
- cmd += ["--permission-mode", self.permission_mode]
123
+ if permission_mode:
124
+ cmd += ["--permission-mode", permission_mode]
111
125
  if self.skip_permissions:
112
126
  cmd.append("--allow-dangerously-skip-permissions")
113
127
  if session_id:
@@ -32,7 +32,7 @@ from __future__ import annotations
32
32
  from pathlib import Path
33
33
  from typing import Any, Literal
34
34
 
35
- from agentflow.backends.base import PermissionPolicy
35
+ from agentflow.backends.base import PermissionPolicy, policy_intent
36
36
  from agentflow.backends.cli_exec import CLIExecBackend, TurnAccumulator
37
37
  from agentflow.events import BackendEvent, TextChunk, ToolCall, ToolResult
38
38
 
@@ -46,7 +46,7 @@ class CodexBackend(CLIExecBackend):
46
46
  self,
47
47
  *,
48
48
  model: str | None = None,
49
- sandbox: str = "read-only",
49
+ sandbox: str | None = None,
50
50
  skip_git_repo_check: bool = True,
51
51
  cwd: Path | str = ".",
52
52
  permission: PermissionPolicy,
@@ -55,16 +55,34 @@ class CodexBackend(CLIExecBackend):
55
55
  ) -> None:
56
56
  super().__init__(cwd=cwd, permission=permission, timeout=timeout)
57
57
  self.model = model
58
+ # ``sandbox`` explicitly set by the caller always wins. When left None
59
+ # it is derived from the permission policy: a deny-all / allowlist
60
+ # policy launches Codex ``read-only`` (Codex has no per-tool gate, so
61
+ # this is the closest upfront enforcement), while AllowAll permits
62
+ # workspace writes. See _effective_sandbox.
58
63
  self.sandbox = sandbox
59
64
  self.skip_git_repo_check = skip_git_repo_check
60
65
  self.extra_args = list(extra_args or [])
61
66
 
67
+ def _effective_sandbox(self) -> str:
68
+ if self.sandbox is not None:
69
+ return self.sandbox # explicit caller choice wins
70
+ intent = policy_intent(self.permission)
71
+ if intent.allow_all:
72
+ return "workspace-write"
73
+ if intent.deny_all or intent.allowlist is not None:
74
+ return "read-only"
75
+ # Interactive/Callback/custom: no static answer — default to the safe
76
+ # read-only sandbox rather than granting writes implicitly.
77
+ return "read-only"
78
+
62
79
  def build_command(self, prompt: str, session_id: str | None) -> list[str]:
63
80
  cmd = ["codex", "exec", "--json"]
64
81
  if self.skip_git_repo_check:
65
82
  cmd.append("--skip-git-repo-check")
66
- if self.sandbox:
67
- cmd += ["-s", self.sandbox]
83
+ sandbox = self._effective_sandbox()
84
+ if sandbox:
85
+ cmd += ["-s", sandbox]
68
86
  if self.model:
69
87
  cmd += ["-m", self.model]
70
88
  cmd += self.extra_args
@@ -30,6 +30,8 @@ from __future__ import annotations
30
30
  import asyncio
31
31
  import contextlib
32
32
  import json
33
+ import os
34
+ import signal
33
35
  from collections.abc import AsyncIterator
34
36
  from pathlib import Path
35
37
  from typing import Any, Literal
@@ -67,12 +69,18 @@ class KiroBackend(BaseAgentBackend):
67
69
  engine: str = "v3",
68
70
  cwd: Path | str = _DEFAULT_CWD,
69
71
  permission: PermissionPolicy,
72
+ close_grace: float = 0.5,
70
73
  ) -> None:
71
74
  super().__init__(permission=permission)
72
75
  self.agent = agent
73
76
  self.model = model
74
77
  self.engine = engine
75
78
  self.cwd = Path(cwd)
79
+ # Per-phase grace on close(): wait this long for a clean exit, then
80
+ # terminate, then (after another grace) kill. Kept short because the
81
+ # v3 engine typically will not exit on stdin close, and we should not
82
+ # block a caller's shutdown for seconds waiting on it.
83
+ self._close_grace = close_grace
76
84
 
77
85
  self._proc: asyncio.subprocess.Process | None = None
78
86
  self._session_id: str | None = None
@@ -114,6 +122,12 @@ class KiroBackend(BaseAgentBackend):
114
122
  # ACP responses (e.g. session/new listing every available mode)
115
123
  # can exceed asyncio's 64KiB default readline buffer; raise it.
116
124
  limit=8 * 1024 * 1024,
125
+ # Put kiro-cli in its own process group so close() can signal
126
+ # the whole tree. The v3 CLI spawns a child (kiro-cli-chat)
127
+ # that outlives a kill of the parent alone, stranding the
128
+ # terminal; killing the group reaps it too. POSIX-only —
129
+ # ignored on platforms without it.
130
+ start_new_session=True,
117
131
  )
118
132
  except OSError as exc:
119
133
  raise BackendTransportError(f"could not start kiro-cli: {exc}") from exc
@@ -133,14 +147,20 @@ class KiroBackend(BaseAgentBackend):
133
147
  try:
134
148
  if proc.stdin and not proc.stdin.is_closing():
135
149
  proc.stdin.close()
150
+ # The v3 engine often does not exit on stdin close (it waits on its
151
+ # own idle timeout), so don't block for seconds hoping it will.
152
+ # Give it a short grace to exit cleanly, then signal the whole
153
+ # process group (SIGTERM, then SIGKILL) so the CLI's child process
154
+ # (kiro-cli-chat) is reaped too, not just the parent. Each phase
155
+ # has a brief window so close() returns promptly.
136
156
  try:
137
- await asyncio.wait_for(proc.wait(), timeout=3)
157
+ await asyncio.wait_for(proc.wait(), timeout=self._close_grace)
138
158
  except TimeoutError:
139
- proc.terminate()
159
+ self._signal_group(proc, signal.SIGTERM)
140
160
  try:
141
- await asyncio.wait_for(proc.wait(), timeout=2)
161
+ await asyncio.wait_for(proc.wait(), timeout=self._close_grace)
142
162
  except TimeoutError:
143
- proc.kill()
163
+ self._signal_group(proc, signal.SIGKILL)
144
164
  finally:
145
165
  if self._reader_task:
146
166
  self._reader_task.cancel()
@@ -149,6 +169,26 @@ class KiroBackend(BaseAgentBackend):
149
169
  self._proc = None
150
170
  self._fail_pending(BackendTransportError("backend closed"))
151
171
 
172
+ @staticmethod
173
+ def _signal_group(proc: asyncio.subprocess.Process, sig: int) -> None:
174
+ """Send ``sig`` to the child's whole process group, if possible.
175
+
176
+ The child was started with ``start_new_session=True`` so it leads its
177
+ own group; signalling ``-pgid`` reaches its descendants too. Falls back
178
+ to signalling just the process where process groups aren't available
179
+ (e.g. Windows) or the group has already gone. Never raises.
180
+ """
181
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError, AttributeError):
182
+ pgid = os.getpgid(proc.pid)
183
+ os.killpg(pgid, sig)
184
+ return
185
+ # Fallback: signal the process directly.
186
+ with contextlib.suppress(ProcessLookupError, OSError):
187
+ if sig == signal.SIGKILL:
188
+ proc.kill()
189
+ else:
190
+ proc.terminate()
191
+
152
192
  # ------------------------------------------------------------------
153
193
  # Public API — one turn as an event stream
154
194
  # ------------------------------------------------------------------
@@ -412,7 +452,7 @@ class KiroBackend(BaseAgentBackend):
412
452
  )
413
453
  req = PermissionRequest(
414
454
  id=str(request_id),
415
- tool=params.get("toolName") or params.get("tool") or "",
455
+ tool=_extract_tool_name(params),
416
456
  options=options,
417
457
  detail=params,
418
458
  )
@@ -508,6 +548,30 @@ class KiroBackend(BaseAgentBackend):
508
548
  queue.put_nowait(event)
509
549
 
510
550
 
551
+ def _extract_tool_name(params: dict[str, Any]) -> str:
552
+ """Pull the tool name out of a session/request_permission payload.
553
+
554
+ Kiro v3 does not use ``toolName``/``tool``; it puts the machine name under
555
+ ``_meta.kiro.toolId`` (and ``_meta.kiro.consent.capability``) and a
556
+ human-readable label under ``toolCall.title``. Fall back through those,
557
+ then to the legacy flat keys, so older shapes still work.
558
+ """
559
+ meta = params.get("_meta")
560
+ if isinstance(meta, dict):
561
+ kiro_meta = meta.get("kiro")
562
+ if isinstance(kiro_meta, dict):
563
+ tool_id = kiro_meta.get("toolId")
564
+ if tool_id:
565
+ return str(tool_id)
566
+ consent = kiro_meta.get("consent")
567
+ if isinstance(consent, dict) and consent.get("capability"):
568
+ return str(consent["capability"])
569
+ tool_call = params.get("toolCall")
570
+ if isinstance(tool_call, dict) and tool_call.get("title"):
571
+ return str(tool_call["title"])
572
+ return params.get("toolName") or params.get("tool") or ""
573
+
574
+
511
575
  def _map_status(status: Any) -> Literal["ok", "error", "pending"]:
512
576
  if status in ("completed", "success", "ok"):
513
577
  return "ok"
@@ -31,6 +31,8 @@ __all__ = [
31
31
  "BackendRateLimitError",
32
32
  "CheckpointError",
33
33
  "CheckpointConflict",
34
+ "StoreError",
35
+ "StoreConflict",
34
36
  "ControlPlaneError",
35
37
  "RunNotFound",
36
38
  "InterruptError",
@@ -138,6 +140,22 @@ class CheckpointConflict(CheckpointError):
138
140
  )
139
141
 
140
142
 
143
+ class StoreError(AgentFlowError):
144
+ """A store operation could not be completed."""
145
+
146
+
147
+ class StoreConflict(StoreError):
148
+ """A conditional store write (``put(..., if_absent=True)``) failed because a
149
+ live item already exists at ``(namespace, key)`` — another writer claimed it
150
+ first. The loser re-reads and reacts (e.g. treats the effect as already
151
+ in-flight or done). This is what makes a claim an atomic single-flight."""
152
+
153
+ def __init__(self, namespace: tuple[str, ...], key: str):
154
+ self.namespace = namespace
155
+ self.key = key
156
+ super().__init__(f"store conflict: {key!r} under {namespace} already exists")
157
+
158
+
141
159
  class RunTimeout(GraphError):
142
160
  """A whole-run timeout fired before the graph reached END.
143
161