Skip to content

Commit 5d96201

Browse files
authored
feat: add bounded runtime readiness probes (#47)
## What - separates side-effect-free package availability from explicit async execution readiness - adds `RuntimeReadiness`, `ReadinessStatus`, optional `RuntimeReadinessProvider`, and bounded `check_readiness()` - exposes readiness through the registry, AgentKit, provider diagnostics, and live smoke path - probes Claude credential signals without claiming local/provider chains are verified - probes Codex through the supported account API with guaranteed temporary-client cleanup - probes Antigravity API-key/ADC setup off the event loop and converts timeout/failure into secret-safe diagnostics ## Why `availability()` mixed package presence with potentially blocking credential discovery. In particular, Antigravity could read files or contact Google metadata services from a synchronous setup check, while Claude/Codex package success was easy to misread as execution readiness. ## Root cause One diagnostic type represented two different questions: “is the adapter installed?” and “is enough setup present to attempt a provider call?” Provider authentication mechanisms also differ in how safely they can be probed. ## Semantics - `availability()`: synchronous, package-only, side-effect-free - `READY_TO_ATTEMPT`: setup signal confirmed, not a guarantee of execution - `NOT_READY`: a concrete package/credential problem was established - `INDETERMINATE`: provider-owned/local chains, timeout, or probe failure require caller policy - readiness remains an optional extension, so the `AgentRuntime` protocol is unchanged ## Checks - `ruff check src tests` - strict `mypy` - full suite: 380 passed, 3 skipped in the all-extras verification environment - delegated run: 371 passed, 12 skipped; 90.92% coverage - `uv lock --check` - `uv build` plus wheel/sdist content inspection ## Stack - Base: #46 - Next: #48
1 parent 900f8ec commit 5d96201

25 files changed

Lines changed: 1454 additions & 100 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- `COMPATIBILITY_MANIFEST` records each built-in adapter's install extra,
2424
import module, accepted SDK range, tested lockfile version, and runtime binary
2525
versions such as `openai-codex-cli-bin`.
26+
- `RuntimeReadiness`, `ReadinessStatus`, the optional
27+
`RuntimeReadinessProvider`, and bounded async `check_readiness()` probes
28+
distinguish `READY_TO_ATTEMPT`, `NOT_READY`, and `INDETERMINATE` without
29+
running an agent task. Registry, `AgentKit`, and provider-diagnostics helpers
30+
expose the same async checks.
2631

2732
### Changed
2833

@@ -37,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3742
tool-filter, and per-MCP-server environment support. Built-in `run()` methods
3843
consume the same support report used by preflight, eliminating divergent
3944
rejection paths.
45+
- `availability()` is now strictly package-only and side-effect-free. Credential
46+
and setup checks moved to async readiness probes: Codex uses the supported
47+
account API with guaranteed cleanup, and Antigravity runs Google ADC discovery
48+
off the event loop. Probe failures/timeouts are secret-safe and indeterminate.
4049

4150
### Fixed
4251

README.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ capabilities visible.
1414
`agent-runtime-kit` is for Python developers who want to run coding-agent tasks
1515
through vendor runtimes without rewriting their application around each SDK. It
1616
normalizes the runtime boundary: task inputs, capability checks, event streams,
17-
tool audits, availability diagnostics, and typed results.
17+
tool audits, package/readiness diagnostics, and typed results.
1818

1919
The library keeps vendor differences visible. Claude, Codex, and Antigravity
2020
still expose different capabilities, permission models, setup requirements, and
@@ -23,7 +23,7 @@ shape instead of hiding them behind a lowest-common-denominator wrapper.
2323

2424
The package is intentionally not a router, benchmark harness, queue, hosted
2525
service, or full agent framework. It is the reusable layer underneath those
26-
systems: task models, runtime capabilities, event sinks, availability
26+
systems: task models, runtime capabilities, event sinks, package/readiness
2727
diagnostics, and adapters.
2828

2929
## Install
@@ -83,7 +83,7 @@ caches them per kind, and turns Python types into structured output.
8383
import asyncio
8484
from dataclasses import dataclass
8585

86-
from agent_runtime_kit import AgentKit
86+
from agent_runtime_kit import AgentKit, ReadinessStatus
8787

8888

8989
@dataclass
@@ -94,9 +94,9 @@ class RepoSummary:
9494

9595
async def main() -> None:
9696
async with AgentKit() as kit:
97-
diagnostic = kit.availability_for("claude")
98-
if not diagnostic.available:
99-
raise RuntimeError(diagnostic.message)
97+
readiness = await kit.readiness_for("claude")
98+
if readiness.status is ReadinessStatus.NOT_READY:
99+
raise RuntimeError(readiness.message)
100100
result = await kit.run(
101101
"claude",
102102
goal="Summarize this repository",
@@ -114,15 +114,15 @@ Adapters also work standalone when you need vendor-specific configuration:
114114
```python
115115
import asyncio
116116

117-
from agent_runtime_kit import AgentTask
117+
from agent_runtime_kit import AgentTask, ReadinessStatus, check_readiness
118118
from agent_runtime_kit.adapters import ClaudeAgentRuntime
119119

120120

121121
async def main() -> None:
122122
runtime = ClaudeAgentRuntime(default_model="claude-sonnet-4-6")
123-
diagnostic = runtime.availability()
124-
if not diagnostic.available:
125-
raise RuntimeError(diagnostic.message)
123+
readiness = await check_readiness(runtime)
124+
if readiness.status is ReadinessStatus.NOT_READY:
125+
raise RuntimeError(readiness.message)
126126
result = await runtime.run(AgentTask(goal="Summarize this repository"))
127127
print(result.output)
128128

@@ -149,6 +149,13 @@ issue. Third-party runtimes can opt into provider-specific checks with the
149149
`TaskSupportProvider` protocol, while older `AgentRuntime` implementations
150150
continue to work through capability-based fallback checks.
151151

152+
`availability()` is deliberately synchronous, side-effect-free, and package-only.
153+
Use `await check_readiness(runtime)` (or `kit.readiness_for(...)`) for an explicit,
154+
bounded credential/setup probe. `READY_TO_ATTEMPT` means setup was positively
155+
detected, not that a future provider call is guaranteed to succeed;
156+
`INDETERMINATE` lets callers decide whether to attempt provider-chain or local
157+
login authentication. Probe failures and timeouts never include credential values.
158+
152159
`AgentResult` returns output, finish reason (see `FinishReason`), locally
153160
validated structured output, usage, cost, session id, tool-call audits, and
154161
provider metadata. `is_success` is true only for a natural, error-free

docs/api-stability.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ import the names from the top-level package instead.
5050
`TaskSupportProvider` is an optional extension, not a new requirement on the
5151
`AgentRuntime` protocol, so existing third-party runtimes retain structural
5252
compatibility and use capability-based fallback checks.
53+
- **Package availability and execution readiness are separate.** Synchronous
54+
`availability()` is package-only and side-effect-free. The bounded async
55+
`check_readiness()` helper uses the optional `RuntimeReadinessProvider`
56+
extension without changing the `AgentRuntime` protocol. A third-party runtime
57+
without the extension maps a missing package to `NOT_READY` and present
58+
package to `INDETERMINATE`. `READY_TO_ATTEMPT` establishes only that known
59+
setup signals are present; it is not a guarantee of future execution.
5360
- **`AgentKit` is sugar, not a second API.** The hub assembles the same frozen
5461
`AgentTask` and returns the same `AgentResult` the runtimes produce
5562
(`ParsedResult` is a runtime-identical subclass adding only the typed

docs/capability-matrix.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
| Streaming output events | Yes — incremental `output.delta` while the SDK runs | No — a single `output.delta` at the end (non-streaming SDK) | Yes — from response chunks |
1313
| Tool audit events | Yes — streamed from message blocks | Yes — emitted from parsed `TurnResult` items after the turn | Yes — from tool chunks |
1414
| `vendor.turn` events | No | No | Yes — from thought/unknown chunks |
15-
| Missing package diagnostics | Yes (`AgentRuntimeUnavailableError`) | Yes (`AgentRuntimeUnavailableError`) | Yes (`AgentRuntimeUnavailableError`) |
16-
| Missing credential diagnostics | No — `availability()` reports available with an `auth_source` label; auth failures surface at `run()` | No — same as Claude (`auth_source` label; deferred) | Yes — `availability()` returns `MISSING_CREDENTIALS` when no API key / ADC-Vertex project is configured |
15+
| Package availability | Sync, side-effect-free, package-only | Sync, side-effect-free, package-only | Sync, side-effect-free, package-only; never calls ADC |
16+
| Async readiness | Direct API key/OAuth/Bedrock bearer signal → `READY_TO_ATTEMPT`; provider/local chains → `INDETERMINATE` | Supported `AsyncCodex.account(refresh_token=False)` probe; absent account → `NOT_READY` | API key or off-loop ADC/project probe; missing → `NOT_READY` |
17+
| Probe failure/timeout | `INDETERMINATE` | `INDETERMINATE`, app-server always closed | `INDETERMINATE` |
1718
| Live smoke test | Opt-in | Opt-in | Opt-in |
1819

1920
The matrix is intentionally not a lowest-common-denominator contract. Adapters

docs/providers.md

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Provider Diagnostics
22

3-
`agent-runtime-kit` keeps provider setup checks explicit. Each runtime exposes
4-
`availability()` and returns a `RuntimeAvailability` value with:
3+
`agent-runtime-kit` separates cheap package discovery from provider setup
4+
probes. Each runtime's synchronous `availability()` is side-effect-free and
5+
package-only. It returns a `RuntimeAvailability` value with:
56

67
- runtime kind
78
- availability flag
@@ -10,6 +11,64 @@
1011
- package name
1112
- installed version when discoverable
1213

14+
Availability never imports a vendor SDK, starts a subprocess, reads a
15+
credential store, calls Google ADC, or contacts a provider. Package presence is
16+
not a claim that execution will work.
17+
18+
Use the public `await check_readiness(runtime, timeout=5.0)` for an explicit
19+
credential/setup probe. Registry and hub forms are also available:
20+
21+
```python
22+
readiness = await registry.readiness_for("codex")
23+
readiness = await kit.readiness_for("codex")
24+
all_readiness = await kit.readiness()
25+
```
26+
27+
`RuntimeReadiness.status` has three outcomes:
28+
29+
- `READY_TO_ATTEMPT`: a supported setup or credential signal was positively
30+
established. It does not promise that a later model/network request succeeds.
31+
- `NOT_READY`: a concrete problem such as a missing package, account, API key,
32+
or ADC project was established.
33+
- `INDETERMINATE`: the runtime uses a provider/local credential chain that
34+
cannot be verified safely, lacks the optional readiness extension, or the
35+
bounded probe failed/timed out. The caller chooses whether to attempt work.
36+
37+
The built-in probes never return credential values or raw exception messages.
38+
They report only safe categories such as auth source and exception type. The
39+
optional `RuntimeReadinessProvider` protocol does not change the required
40+
`AgentRuntime` protocol. For older third-party runtimes,
41+
`check_readiness()` maps negative availability to `NOT_READY` and positive
42+
package presence conservatively to `INDETERMINATE`.
43+
44+
Provider behavior is deliberately specific:
45+
46+
- Claude treats direct API key, OAuth-token, and Bedrock bearer-token signals
47+
as `READY_TO_ATTEMPT`. Provider chains and provider-owned local login remain
48+
`INDETERMINATE`; probing them would require starting a real task or scraping
49+
unsupported credential stores.
50+
- Codex starts the supported `AsyncCodex` client, calls
51+
`account(refresh_token=False)`, and always closes the app-server context. An
52+
account is `READY_TO_ATTEMPT`, no account is `NOT_READY`, and startup/API/
53+
cleanup failures are `INDETERMINATE`.
54+
- Antigravity accepts an explicit or ambient Gemini API key, otherwise probes
55+
Google Application Default Credentials and project setup in a worker thread
56+
so synchronous Google discovery cannot block the event loop. Missing setup
57+
is `NOT_READY`; errors and timeouts are `INDETERMINATE`.
58+
59+
For all built-ins at once, the existing sync collector stays package-only while
60+
the async collector performs bounded readiness checks:
61+
62+
```python
63+
from agent_runtime_kit.adapters.diagnostics import (
64+
collect_provider_diagnostics,
65+
collect_provider_readiness,
66+
)
67+
68+
packages = collect_provider_diagnostics()
69+
readiness = await collect_provider_readiness(timeout=5.0)
70+
```
71+
1372
## Install Model
1473

1574
The plain `agent-runtime-kit` package is the vendor-SDK-free core and includes

docs/quickstart.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,10 @@ runtime = ClaudeAgentRuntime(default_model="claude-sonnet-4-6", reuse_process=Tr
122122
result = await runtime.run(AgentTask(goal="Summarize this repository"))
123123
```
124124

125-
Use `kit.availability()` (or `runtime.availability()`) before dispatching work
126-
in applications that need clear setup diagnostics.
125+
Use synchronous `kit.availability()` (or `runtime.availability()`) for a
126+
side-effect-free package check. Use `await kit.readiness()` or
127+
`await check_readiness(runtime)` when an application also needs a bounded
128+
credential/setup probe. Treat `ReadinessStatus.READY_TO_ATTEMPT` as permission
129+
to try — not a guarantee that the provider, model, or network will accept the
130+
next request. `NOT_READY` is a confirmed setup problem; `INDETERMINATE` leaves
131+
the attempt policy to the caller.

src/agent_runtime_kit/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
ParsedResult,
2626
PermissionMode,
2727
PermissionProfile,
28+
ReadinessStatus,
2829
RuntimeAvailability,
30+
RuntimeReadiness,
31+
RuntimeReadinessProvider,
2932
SessionResumeState,
3033
TaskSupportIssue,
3134
TaskSupportProvider,
@@ -50,6 +53,7 @@
5053
tool_requested_event,
5154
vendor_turn_event,
5255
)
56+
from agent_runtime_kit.readiness import DEFAULT_READINESS_TIMEOUT, check_readiness
5357
from agent_runtime_kit.registry import RuntimeRegistry, create_default_registry
5458
from agent_runtime_kit.support import validate_task
5559

@@ -64,22 +68,26 @@
6468
"AgentTask",
6569
"ArtifactRef",
6670
"AvailabilityReason",
71+
"COMPATIBILITY_MANIFEST",
72+
"DEFAULT_READINESS_TIMEOUT",
6773
"EventSink",
6874
"FakeAgentRuntime",
6975
"FilesystemAccess",
7076
"FinishReason",
7177
"KIND_ALIASES",
72-
"COMPATIBILITY_MANIFEST",
7378
"McpServerConfig",
7479
"OutputSchemaError",
7580
"OutputTypeError",
7681
"PackageVersion",
7782
"ParsedResult",
7883
"PermissionMode",
7984
"PermissionProfile",
85+
"ReadinessStatus",
8086
"RuntimeAvailability",
8187
"RuntimeCompatibility",
8288
"RuntimeNotRegisteredError",
89+
"RuntimeReadiness",
90+
"RuntimeReadinessProvider",
8391
"RuntimeRegistry",
8492
"SessionResumeState",
8593
"TaskSupportIssue",
@@ -90,6 +98,7 @@
9098
"Usage",
9199
"create_default_registry",
92100
"compatibility_for",
101+
"check_readiness",
93102
"runtime_kind_value",
94103
"output_delta_event",
95104
"safe_emit",

src/agent_runtime_kit/_kit.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
PermissionMode,
2626
PermissionProfile,
2727
RuntimeAvailability,
28+
RuntimeReadiness,
2829
SessionResumeState,
2930
TaskSupportReport,
3031
)
32+
from agent_runtime_kit.readiness import DEFAULT_READINESS_TIMEOUT, check_readiness
3133
from agent_runtime_kit.registry import RuntimeFactory, RuntimeRegistry, create_default_registry
3234
from agent_runtime_kit.support import validate_task as validate_runtime_task
3335

@@ -64,8 +66,8 @@ class AgentKit:
6466
6567
``AgentKit()`` builds a registry with the fake runtime and the vendor
6668
adapters registered (adapters resolve their SDKs lazily, so this works
67-
without any extra installed — ``availability_for`` reports what is
68-
missing). Pass ``registry=`` to bring your own; the other flags then do
69+
without any extra installed — ``availability_for`` reports missing
70+
packages). Pass ``registry=`` to bring your own; the other flags then do
6971
not apply.
7072
7173
Runtimes resolved through the hub are constructed zero-arg, cached per
@@ -101,13 +103,37 @@ def kinds(self) -> tuple[AgentRuntimeKind | str, ...]:
101103
return self._registry.kinds()
102104

103105
def availability(self) -> tuple[RuntimeAvailability, ...]:
104-
"""Availability diagnostics for every registered kind."""
106+
"""Side-effect-free package diagnostics for every registered kind."""
105107

106108
return tuple(self._registry.availability_for(kind) for kind in self._registry.kinds())
107109

108110
def availability_for(self, kind: AgentRuntimeKind | str) -> RuntimeAvailability:
109111
return self._registry.availability_for(self._normalize_kind(kind))
110112

113+
async def readiness(
114+
self,
115+
*,
116+
timeout: float = DEFAULT_READINESS_TIMEOUT,
117+
) -> tuple[RuntimeReadiness, ...]:
118+
"""Probe every registered runtime concurrently."""
119+
120+
return tuple(
121+
await asyncio.gather(
122+
*(self.readiness_for(kind, timeout=timeout) for kind in self.kinds())
123+
)
124+
)
125+
126+
async def readiness_for(
127+
self,
128+
kind: AgentRuntimeKind | str,
129+
*,
130+
timeout: float = DEFAULT_READINESS_TIMEOUT,
131+
) -> RuntimeReadiness:
132+
"""Probe one cached runtime without executing an agent task."""
133+
134+
runtime = await self._runtime_for(self._normalize_kind(kind))
135+
return await check_readiness(runtime, timeout=timeout)
136+
111137
def capabilities_for(self, kind: AgentRuntimeKind | str) -> AgentCapabilities:
112138
return self._registry.capabilities_for(self._normalize_kind(kind))
113139

src/agent_runtime_kit/_runtime.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AgentRuntimeKind,
1313
AgentTask,
1414
RuntimeAvailability,
15+
RuntimeReadiness,
1516
TaskSupportReport,
1617
ToolCallAudit,
1718
)
@@ -64,6 +65,17 @@ def validate_task(self, task: AgentTask) -> TaskSupportReport:
6465

6566
return _validate_declared_task_support(self.kind, self.capabilities, task)
6667

68+
async def check_readiness(self) -> RuntimeReadiness:
69+
"""Fake runtime is deterministic and always ready to attempt."""
70+
71+
availability = self.availability()
72+
return RuntimeReadiness.ready_to_attempt(
73+
self.kind,
74+
package=availability.package,
75+
version=availability.version,
76+
metadata=availability.metadata,
77+
)
78+
6779
async def run(self, task: AgentTask) -> AgentResult:
6880
"""Return a deterministic result after validating capabilities."""
6981

0 commit comments

Comments
 (0)