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.
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.gitignore +3 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/CHANGELOG.md +94 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/PKG-INFO +17 -11
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/README.md +13 -10
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/__init__.py +16 -1
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/_http.py +6 -3
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/base.py +78 -1
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/claude_code.py +19 -5
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/codex.py +22 -4
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/kiro.py +69 -5
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/errors.py +18 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/events.py +25 -0
- agent_workflow_sdk-0.2.0/agentflow/interrupts.py +113 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/__init__.py +12 -0
- agent_workflow_sdk-0.2.0/agentflow/prebuilt/effects.py +319 -0
- agent_workflow_sdk-0.2.0/agentflow/prebuilt/gate.py +196 -0
- agent_workflow_sdk-0.2.0/agentflow/prebuilt/idempotent.py +105 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/runtime.py +7 -3
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/base.py +11 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/memory.py +9 -1
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/postgres.py +29 -0
- agent_workflow_sdk-0.2.0/docs/backends/agent-backends.md +102 -0
- agent_workflow_sdk-0.2.0/docs/backends/llm-backends.md +106 -0
- agent_workflow_sdk-0.2.0/docs/backends/overview.md +81 -0
- agent_workflow_sdk-0.2.0/docs/backends/permissions.md +116 -0
- agent_workflow_sdk-0.2.0/docs/backends/retries.md +75 -0
- agent_workflow_sdk-0.2.0/docs/concepts/context.md +50 -0
- agent_workflow_sdk-0.2.0/docs/concepts/execution.md +52 -0
- agent_workflow_sdk-0.2.0/docs/concepts/nodes-and-edges.md +66 -0
- agent_workflow_sdk-0.2.0/docs/concepts/running.md +72 -0
- agent_workflow_sdk-0.2.0/docs/concepts/state.md +57 -0
- agent_workflow_sdk-0.2.0/docs/concepts/subgraphs.md +49 -0
- agent_workflow_sdk-0.2.0/docs/durability/checkpointing.md +103 -0
- agent_workflow_sdk-0.2.0/docs/durability/control-plane.md +113 -0
- agent_workflow_sdk-0.2.0/docs/durability/human-in-the-loop.md +117 -0
- agent_workflow_sdk-0.2.0/docs/durability/observability.md +108 -0
- agent_workflow_sdk-0.2.0/docs/durability/quality-gates.md +122 -0
- agent_workflow_sdk-0.2.0/docs/durability/store.md +117 -0
- agent_workflow_sdk-0.2.0/docs/get-started/installation.md +41 -0
- agent_workflow_sdk-0.2.0/docs/get-started/mental-model.md +36 -0
- agent_workflow_sdk-0.2.0/docs/get-started/overview.md +21 -0
- agent_workflow_sdk-0.2.0/docs/get-started/quickstart.md +132 -0
- agent_workflow_sdk-0.2.0/docs/index.md +57 -0
- agent_workflow_sdk-0.2.0/docs/patterns/long-running-loops.md +182 -0
- agent_workflow_sdk-0.2.0/docs/patterns/prebuilt.md +110 -0
- agent_workflow_sdk-0.2.0/docs/patterns/tutorial-qa-agent.md +139 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/backends.md +26 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/checkpoint.md +15 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/compiled.md +5 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/controlplane.md +17 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/errors.md +26 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/events.md +22 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/gate.md +28 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/graph.md +13 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/interrupts.md +10 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/observability.md +16 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/permissions.md +13 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/prebuilt.md +14 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/runtime.md +7 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/state.md +15 -0
- agent_workflow_sdk-0.2.0/docs/reference/api/store.md +11 -0
- agent_workflow_sdk-0.2.0/docs/reference/api.md +210 -0
- agent_workflow_sdk-0.2.0/docs/reference/glossary.md +123 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/agents_demo.py +6 -7
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/hello_graph.py +4 -2
- agent_workflow_sdk-0.2.0/examples/hitl_permission.py +125 -0
- agent_workflow_sdk-0.2.0/examples/idempotent_effect.py +100 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/optimize_loop.py +5 -0
- agent_workflow_sdk-0.2.0/examples/quality_gate.py +125 -0
- agent_workflow_sdk-0.2.0/examples/research_plan_implement_review.py +154 -0
- agent_workflow_sdk-0.2.0/mkdocs.yml +151 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/pyproject.toml +2 -1
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_cli_backends.py +58 -0
- agent_workflow_sdk-0.2.0/tests/test_effects.py +342 -0
- agent_workflow_sdk-0.2.0/tests/test_gate.py +204 -0
- agent_workflow_sdk-0.2.0/tests/test_idempotent.py +144 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_kiro_backend.py +26 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_permission_policy.py +36 -0
- agent_workflow_sdk-0.2.0/tests/test_prebuilt_no_httpx.py +64 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_store.py +48 -0
- agent_workflow_sdk-0.1.0/examples/hitl_permission.py +0 -107
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.env.example +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/.github/workflows/ci.yml +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/CONTRIBUTING.md +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/DESIGN.md +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/LICENSE +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/NOTICE +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/SECURITY.md +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/__init__.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/anthropic.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/cli_exec.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/ollama.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/backends/openai.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/__init__.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/_serde.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/base.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/file.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/memory.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/postgres.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/redis.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/checkpoint/sqlite.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/compiled.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/__init__.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/memory.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/postgres.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/queue.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/records.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/registry.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/controlplane/worker.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/graph.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/observability.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/otel.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/loop.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/resilience.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prebuilt/tool_loop.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/prometheus.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/py.typed +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/redaction.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/state.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/__init__.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/store/_util.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/agentflow/telemetry.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/codebase_qa_ollama.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/mixed_backends.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/telemetry_demo.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/examples/tool_loop_ollama.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/conftest.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_anthropic_backend.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_backend_lifecycle.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_checkpoint_contract.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_concurrency.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_controlplane.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_graph_engine.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_hitl_permission.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_http_retry.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_input_validation.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_live_backends.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_observability.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_ollama_backend.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_openai_backend.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_otel.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_prebuilt_loop.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_prometheus.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_provider_resilience.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_redaction.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_redis_checkpointer.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_resilience.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_run_timeout.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_sqlite_checkpointer.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_state_isolation.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_step_atomicity.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_subgraph.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_sync_facade.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_telemetry.py +0 -0
- {agent_workflow_sdk-0.1.0 → agent_workflow_sdk-0.2.0}/tests/test_tool_loop.py +0 -0
|
@@ -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.
|
|
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`, `
|
|
288
|
-
|
|
289
|
-
|
|
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 —
|
|
301
|
+
Capability matrix — how the policy is applied:
|
|
297
302
|
|
|
298
|
-
| Backend |
|
|
303
|
+
| Backend | How `PermissionPolicy` applies |
|
|
299
304
|
| --- | --- |
|
|
300
|
-
| `KiroBackend` |
|
|
301
|
-
| `CodexBackend` |
|
|
302
|
-
| `ClaudeCodeBackend` |
|
|
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
|
|
305
|
-
|
|
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`, `
|
|
246
|
-
|
|
247
|
-
|
|
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 —
|
|
256
|
+
Capability matrix — how the policy is applied:
|
|
255
257
|
|
|
256
|
-
| Backend |
|
|
258
|
+
| Backend | How `PermissionPolicy` applies |
|
|
257
259
|
| --- | --- |
|
|
258
|
-
| `KiroBackend` |
|
|
259
|
-
| `CodexBackend` |
|
|
260
|
-
| `ClaudeCodeBackend` |
|
|
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
|
|
263
|
-
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
110
|
-
cmd += ["--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 =
|
|
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
|
-
|
|
67
|
-
|
|
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=
|
|
157
|
+
await asyncio.wait_for(proc.wait(), timeout=self._close_grace)
|
|
138
158
|
except TimeoutError:
|
|
139
|
-
proc.
|
|
159
|
+
self._signal_group(proc, signal.SIGTERM)
|
|
140
160
|
try:
|
|
141
|
-
await asyncio.wait_for(proc.wait(), timeout=
|
|
161
|
+
await asyncio.wait_for(proc.wait(), timeout=self._close_grace)
|
|
142
162
|
except TimeoutError:
|
|
143
|
-
proc.
|
|
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=
|
|
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
|
|