Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,13 +552,20 @@ that as every skill having been revoked and delete the files it wrote on a previ
against a store that has not received a payload reports the retrieval unavailable and leaves
everything on disk alone. `report.ok` is `False` in that case, and the error names it.

**A project with no skills yet is a waiting state, not a failure.** Every request declares the
payload it wants (`kinds=agent-skill`), and LaunchDarkly answers HTTP 422 when the environment
has no such payload — which is the case until somebody creates the first skill in the project.
The store logs it once, keeps asking at its backoff cap, and picks up that first skill without
a restart. `failed` stays `None` throughout, the 422s are kept off `connection_failures` and
`last_error`, and `diagnostics.payload_unavailable` counts them: nonzero and rising beside an
empty store means "there is nothing to deliver", not "delivery is broken".
**A 422 means this connection will never be assigned a skill payload, and delivery stops.**
Every request declares the payload it wants (`kinds=agent-skill`), and LaunchDarkly answers
HTTP 422 when it will not serve one. The cause you can act on is a **view-scoped SDK key**:
a key restricted to a view cannot be assigned a skill payload, so check the key's scoping and
use one that is not view-scoped. The other cause is that Agent Skills delivery is not enabled
for your account, which is not a setting you control — contact LaunchDarkly support if the key
is not the problem. Retrying fixes neither, and LaunchDarkly chose the status so that SDKs stop
rather than hammer the fleet, so the store gives up: `failed` carries the reason, `lastError` is
populated, `connectionFailures` stays at zero (it counts consecutive *recoverable* failures against
the retry bound, which a fatal never spends), and `waitForSkills` resolves `false` immediately
instead of at your timeout. It is **not** the answer for an environment that merely has no skills
yet — with delivery enabled and a non-view-scoped key, an environment holding zero skills is served
an empty payload that commits normally. Because delivery has stopped for good, a process that booted
while the cause was in effect picks up skills only after a restart.

**Nothing above the store changes.** The accessors, verification, and `write_skills` see raw
objects through the `SkillStore` interface and cannot tell which store produced them.
Expand Down Expand Up @@ -641,7 +648,7 @@ Windows.
| `InMemorySkillStore(objects=None)` | A dict-backed store with `put(raw)`, for local development and testing. Holds several versions of a key. |
| `FDv2SkillStore(sdk_key, *, base_uri=…, stream_uri=…, mode="stream", …)` | The delivery transport: a store fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `wait_for_skills(timeout)`, `is_initialized()`, `close()`, `diagnostics`, `failed`; also a context manager. `base_uri` and `stream_uri` are separate hosts, defaulting to LaunchDarkly's polling and streaming origins; `base_uri` alone covers both. `close()` is **final** — `start()` afterwards raises. **Server-side only** — a mobile key or client-side environment ID raises. See *Receiving skills from LaunchDarkly* above. |
| `watch_skills(skills, root, *, debounce=0.5, on_reconcile=None, …)` | `write_skills` plus a re-reconcile on every delivery change. Returns `(initial report, SkillWatcher)`; close the watcher when done. Revocation then takes effect within `debounce` of arriving rather than at the next restart. `debounce` is in **seconds** and must be non-negative and finite; `on_reconcile` is called with each *subsequent* report, the initial one being returned directly. One watcher per root. |
| `StoreDiagnostics` | What the transport has seen: `payloads_transferred`, `skill_objects_received`, `objects_ignored`, `objects_revoked`, `payloads_ignored`, `hashless_objects`, `connection_failures`, `payload_unavailable`, `last_error`. |
| `StoreDiagnostics` | What the transport has seen: `payloads_transferred`, `skill_objects_received`, `objects_ignored`, `objects_revoked`, `payloads_ignored`, `hashless_objects`, `connection_failures`, `last_error`. |

Configure the store with `init_client(options={"skillStore": store})`. With none configured,
the accessors raise `RuntimeError` explaining what to do and `write_skills` reports the
Expand Down
12 changes: 1 addition & 11 deletions packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,7 @@ strings, held apart on purpose. No `mv`: that parameter selects the *flag* data
delivery overrides whatever a request asks for with the payload's own default for any
non-flagging payload, so sending it would state a preference that is ignored.

**HTTP 422 is not a failure.** It is the answer to that declaration when the credential is
assigned no agent-skill payload, which is every project in which no skill has ever been created
— LaunchDarkly creates that payload with the environment's first skill, never in advance. So
`_classify_status` maps it to `_NoSkillPayloadError`, and `_run` catches that **ahead of**
`_RecoverableTransportError`: counted under `diagnostics.payload_unavailable`, logged once per
store, retried at `max_backoff` indefinitely, and kept off `connection_failures`, `last_error`,
and `failed`. Neither of the two obvious classifications is right — as a recoverable failure it
spends `max_consecutive_failures` and then gives up permanently on an ordinary configuration;
as a fatal one, the skill created a minute later never arrives without a process restart. The
retry is at the *cap* rather than the initial backoff because `_failures` deliberately never
moves, so the exponential schedule would sit at the initial delay forever.
**HTTP 422 is fatal, and the platform decided that rather than the SDK inferring it.** Delivery answers 422 when a connection's declared kinds exclude every payload it is assigned, and it picked a non-400 4xx *because* LD SDKs treat those as terminal. So `classifyStatus` returns a `FatalTransportError` and the existing give-up path handles it: `failed` and `lastError` are set, `connectionFailures` is untouched (it measures consecutive *recoverable* failures against the retry bound, and a fatal never retries), and `waitForSkills` resolves `false` at once rather than at the timeout.

**The key travels over TLS only, and only to the base URI.** `_require_https_base_uri` refuses a
plain `http://` base URI in the constructor — the SDK key would go out in cleartext — with a
Expand Down
72 changes: 7 additions & 65 deletions packages/client/src/launchdarkly_ai_server/skills_fdv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,16 +308,6 @@ class StoreDiagnostics:
"""
connection_failures: int = 0
"""Recoverable transport failures since the last successful transfer."""
payload_unavailable: int = 0
"""
Requests answered "no payload of the kind you asked for" (HTTP 422), which
is the answer until the first skill is created in this project. Cumulative,
and never reset.

Deliberately not counted as a connection failure: nothing is wrong, there is
nothing to deliver yet. Nonzero and rising alongside an empty store means
this environment has no skills, not that delivery is broken.
"""
last_error: str | None = None
"""The most recent transport error, if any. Human-readable; do not parse."""

Expand Down Expand Up @@ -1004,26 +994,6 @@ def __init__(self, message: str, retry_after: float | None = None) -> None:
self.retry_after = retry_after


class _NoSkillPayloadError(_RecoverableTransportError):
"""
An HTTP 422: delivery has no payload of the kind this store declared.

That is the answer for every project in which no skill has ever been
created, since the agent-skill payload row is created with the first one.
Neither of the two obvious classifications is right, which is why this is
its own class:

- as a failure it would spend ``max_consecutive_failures`` and then give up
permanently -- "gave up after N consecutive failures" -- on a
configuration that is merely waiting for its first skill;
- as fatal, the skill created a minute later would never arrive, because
nothing reopens delivery short of a process restart.

``_run`` handles it ahead of ``_RecoverableTransportError``, which it
subclasses.
"""


class _StaleRequestStateError(_RecoverableTransportError):
"""
An HTTP 400 for a request carrying client state — the ``basis`` selector, or
Expand Down Expand Up @@ -1110,12 +1080,13 @@ def _classify_status(status: int, headers: Any) -> Exception:
f"LaunchDarkly returned HTTP 400. {_REQUEST_ADVICE}"
)
if status == 422:
return _NoSkillPayloadError(
"LaunchDarkly has no Agent Skills payload for this environment "
"(HTTP 422). That is the answer until the first skill is created in "
"this project; when one is, it reaches this store without a restart. "
"If this environment does have skills, check that this SDK key "
"belongs to it."
# Delivery refuses a connection whose declared kinds exclude every
# payload it is assigned, and chose a non-400 4xx precisely so SDKs stop
# instead of retrying.
return _FatalTransportError(
"LaunchDarkly will not deliver Agent Skills on this connection "
"(HTTP 422). The usual cause is a view-scoped SDK key. Check your "
"SDK key or contact LaunchDarkly support.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

422 error message is truncated

High Severity

The HTTP 422 _FatalTransportError string is cut off mid-sentence, so the module does not parse and failed never names account-level enablement or that a restart is required. test_the_422_message_names_both_of_its_real_causes asserts those tokens on the customer-facing reason.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a359b3b. Configure here.

)
if status in (405, 406, 414, 501):
return _FatalTransportError(
Expand Down Expand Up @@ -1699,9 +1670,6 @@ def __init__(
# A stream only ever ends by being dropped, so this is what separates
# a recycled healthy connection from one that failed.
self._attempt_answered = False
# Said once per store rather than once per attempt: the condition holds
# until somebody creates a skill, and delivery keeps asking throughout.
self._warned_no_skill_payload = False

# -- lifecycle ---------------------------------------------------------

Expand Down Expand Up @@ -1959,32 +1927,6 @@ def _run(self) -> None:
except _FatalTransportError as exc:
self._give_up(str(exc))
return
except _NoSkillPayloadError as exc:
# Ahead of the recoverable block below, none of which applies:
# nothing failed, so there is no count to advance and no
# ``last_error`` to leave on a store that is working fine.
if self._stop.is_set():
return
with self._lock:
# A 422 refuses the connection before it opens, so this is
# only for a payload an earlier attempt left in flight.
self._reader._abandon_in_flight()
self._reader.diagnostics.payload_unavailable += 1
say_it = not self._warned_no_skill_payload
self._warned_no_skill_payload = True
if say_it:
logger.warning(
"Skill delivery is idle: %s Retrying every %.1fs. This "
"is logged once.",
exc,
self._max_backoff,
)
# At the cap rather than on the backoff schedule: ``_failures``
# deliberately never moves, so the schedule would hold this at
# the *initial* delay forever.
if self._stop.wait(self._max_backoff):
return
continue
except _RecoverableTransportError as exc:
if self._stop.is_set():
# ``close`` interrupted the request on purpose. Counting it
Expand Down
Loading
Loading