agentgovern 2.2.0__tar.gz → 2.2.1__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.
- {agentgovern-2.2.0 → agentgovern-2.2.1}/CHANGELOG.md +41 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/PKG-INFO +1 -1
- agentgovern-2.2.1/agentgovern/_version.py +1 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/client.py +195 -15
- {agentgovern-2.2.0 → agentgovern-2.2.1}/pyproject.toml +1 -1
- agentgovern-2.2.1/tests/test_client.py +512 -0
- agentgovern-2.2.1/tests/test_register_contract.py +120 -0
- agentgovern-2.2.0/agentgovern/_version.py +0 -1
- agentgovern-2.2.0/tests/test_client.py +0 -210
- {agentgovern-2.2.0 → agentgovern-2.2.1}/.gitignore +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/LICENSE +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/README.md +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/RELEASING.md +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/__main__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/exceptions.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/gate2.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/cli.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/data/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/data/hook.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/drift.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/installer.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/settings.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/_base.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/langchain.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/langgraph.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/types.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/client.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/types.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/examples/langgraph_credit_decision_agent.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/__init__.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/conftest.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_bundled_hook.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_drift.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_context_snapshot.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_dispatch.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_outbox_queue.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_payload_extraction.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_payloads.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_remediation_message.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_timing_log.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_transcript_reading.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_turn_index.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_warn_verdict.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_installer.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_settings.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_base_instrumentor.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_evaluate_input.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_full_path.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_latency.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_verdict_handler.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_instrument_langchain.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain_gate1.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain_gate2_callbacks.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langgraph.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_output_capture_contract.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_payload_schema.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_rate_limit_handling.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_timestamp.py +0 -0
- {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_version_consistency.py +0 -0
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.1 — 2026-08-13
|
|
4
|
+
|
|
5
|
+
### Fix — `register_agent()` silently no-op'd when called immediately after `init()`, the SDK's own documented call pattern (STORY-109)
|
|
6
|
+
|
|
7
|
+
`register_agent()` previously scheduled its HTTP call directly against the
|
|
8
|
+
background thread's event loop, gated on that loop already being confirmed
|
|
9
|
+
running (`if self._loop is not None and self._loop.is_running():`). If
|
|
10
|
+
called before the loop was ready — typically `<100ms` after `init()`, and
|
|
11
|
+
this SDK's own README quickstart calls `register_agent()` on the very next
|
|
12
|
+
line after `init()` — the registration was dropped with nothing queued
|
|
13
|
+
anywhere and no error surfaced. No explicit `register_agent()` call had
|
|
14
|
+
ever landed successfully in any installation of this SDK, for any agent,
|
|
15
|
+
in over three months; every registered-looking agent row was actually the
|
|
16
|
+
ingest route's own auto-provisioning fallback.
|
|
17
|
+
|
|
18
|
+
- `register_agent()` now enqueues onto a dedicated buffer instead of
|
|
19
|
+
scheduling directly — the loop-readiness dependency is removed from the
|
|
20
|
+
call entirely. Drained by a new internal flush routine on the same
|
|
21
|
+
cadence actions already use.
|
|
22
|
+
- Deduped by `external_id`: calling `register_agent()` more than once for
|
|
23
|
+
the same agent before a flush collapses to one pending entry with the
|
|
24
|
+
latest values, matching the server's own always-overwrite upsert
|
|
25
|
+
behavior, rather than queuing one call per invocation.
|
|
26
|
+
- A failed registration now prints a `stderr` warning after repeated
|
|
27
|
+
consecutive failures, matching the existing warning behavior for action
|
|
28
|
+
ingest failures — never raises into the caller, but no longer silent
|
|
29
|
+
either.
|
|
30
|
+
- `status()` gained two new informational fields:
|
|
31
|
+
`reg_buffer_size`, `reg_consecutive_failures`.
|
|
32
|
+
- Internal null-strip added to the registration payload before
|
|
33
|
+
serialization, generalizing the same fix already applied to action
|
|
34
|
+
ingest (`_post_batch`) in `0.1.0b4` — Pydantic's `model_dump_json()`
|
|
35
|
+
sends unset optional fields as explicit JSON `null`; the server's schema
|
|
36
|
+
treats that as a distinct value from an absent key.
|
|
37
|
+
|
|
38
|
+
No public API changes — `register_agent()`'s signature is unchanged, and
|
|
39
|
+
every internal addition is private. Patch release: this restores
|
|
40
|
+
previously-documented behavior rather than adding new capability.
|
|
41
|
+
|
|
42
|
+
Full design and build record: `docs/audit-reports/story-109-register-agent-startup-race-2026-08-13.md`.
|
|
43
|
+
|
|
3
44
|
## 2.2.0 — 2026-08-11
|
|
4
45
|
|
|
5
46
|
### Fix — capture-only hook events (UserPromptSubmit/PostToolUse/Stop) no longer block on a discarded synchronous verdict; async audit-event parity (NAGT-137)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentgovern
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Compliance-as-code middleware for agentic AI workflows.
|
|
5
5
|
Project-URL: Homepage, https://agentgovern.zirahn.com
|
|
6
6
|
Project-URL: Repository, https://github.com/ahmedkhan-zirahn/agentgovern
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.2.1"
|
|
@@ -59,6 +59,20 @@ _BUFFER_MAXLEN = 10_000 # drop oldest when full
|
|
|
59
59
|
_WARN_AFTER_FAILURES = 3 # consecutive failures before stderr warning
|
|
60
60
|
_BACKOFF_BASE = 1.0 # initial retry delay in seconds
|
|
61
61
|
_BACKOFF_MAX = 30.0 # maximum retry delay in seconds
|
|
62
|
+
# STORY-109 (security review): distinct-external_id cap on self._reg_buffer,
|
|
63
|
+
# on top of the dedupe register_agent() already does. Registering more than
|
|
64
|
+
# this many DISTINCT agents from one process between flush ticks (every
|
|
65
|
+
# ~_FLUSH_INTERVAL) is not a realistic usage pattern -- this exists to bound
|
|
66
|
+
# a caller bug (e.g. register_agent() with a dynamically-generated
|
|
67
|
+
# external_id in a hot loop), not to constrain legitimate use.
|
|
68
|
+
_REG_BUFFER_MAXLEN = 1_000
|
|
69
|
+
# Cap how many pending registrations _flush_registrations() processes per
|
|
70
|
+
# call, matching the same per-tick discipline self.batch_size already
|
|
71
|
+
# enforces for actions. Actions are the higher-volume path that matters more
|
|
72
|
+
# to a customer; an outage plus many queued registrations must not starve
|
|
73
|
+
# action ingestion behind retries for registrations that can simply wait
|
|
74
|
+
# for the next tick.
|
|
75
|
+
_REG_BATCH_SIZE = 100
|
|
62
76
|
|
|
63
77
|
|
|
64
78
|
class AgentGovernClient:
|
|
@@ -100,7 +114,27 @@ class AgentGovernClient:
|
|
|
100
114
|
self.flush_interval = flush_interval
|
|
101
115
|
|
|
102
116
|
self._buffer: collections.deque[ActionRecord] = collections.deque(maxlen=_BUFFER_MAXLEN)
|
|
117
|
+
# STORY-109: separate from self._buffer, and keyed by external_id
|
|
118
|
+
# rather than a plain deque -- deliberately, not a second
|
|
119
|
+
# maxlen-capped deque. Two reasons:
|
|
120
|
+
# 1. A shared, bounded buffer evicts oldest-first on overflow --
|
|
121
|
+
# since registration is appended once, before any actions
|
|
122
|
+
# exist, it would be structurally the first thing dropped.
|
|
123
|
+
# 2. Registration is idempotent server-side (both ingest routes'
|
|
124
|
+
# onConflictDoUpdate always overwrite with the latest values),
|
|
125
|
+
# so a caller calling register_agent() more than once for the
|
|
126
|
+
# same agent should collapse to one pending entry, not queue
|
|
127
|
+
# one per call -- an OrderedDict keyed by external_id gives
|
|
128
|
+
# that dedupe for free (re-assigning an existing key updates
|
|
129
|
+
# its value in place, without moving its position or growing
|
|
130
|
+
# the buffer). _REG_BUFFER_MAXLEN (security review) is a
|
|
131
|
+
# generous cap on DISTINCT external_ids on top of that dedupe,
|
|
132
|
+
# for the caller-bug case dedupe alone doesn't cover.
|
|
133
|
+
self._reg_buffer: collections.OrderedDict[str, AgentRegistration] = (
|
|
134
|
+
collections.OrderedDict()
|
|
135
|
+
)
|
|
103
136
|
self._consecutive_failures = 0
|
|
137
|
+
self._reg_consecutive_failures = 0
|
|
104
138
|
self._shutdown_event = threading.Event()
|
|
105
139
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
106
140
|
self._thread = threading.Thread(
|
|
@@ -165,7 +199,28 @@ class AgentGovernClient:
|
|
|
165
199
|
tags: list[str] | None = None,
|
|
166
200
|
metadata: dict[str, Any] | None = None,
|
|
167
201
|
) -> None:
|
|
168
|
-
"""
|
|
202
|
+
"""
|
|
203
|
+
Enqueue an agent registration for async ingest. Returns immediately,
|
|
204
|
+
no I/O. Never raises.
|
|
205
|
+
|
|
206
|
+
STORY-109: previously scheduled the HTTP call directly via
|
|
207
|
+
`asyncio.run_coroutine_threadsafe`, gated on `self._loop` already
|
|
208
|
+
being set and running -- silently dropped, with nothing queued
|
|
209
|
+
anywhere, if called before the background thread's event loop was
|
|
210
|
+
confirmed ready (typically <100ms after `__init__`, and the SDK's
|
|
211
|
+
own documented quickstart calls this on the very next line after
|
|
212
|
+
`init()`, which reliably lands inside that window rather than
|
|
213
|
+
sometimes avoiding it). Enqueuing onto `self._reg_buffer` removes
|
|
214
|
+
the loop-readiness dependency from this call entirely -- the buffer
|
|
215
|
+
exists before the background thread does anything, so there is no
|
|
216
|
+
state this call needs to wait for.
|
|
217
|
+
|
|
218
|
+
Dedupes by `external_id` (security review): registering the same
|
|
219
|
+
agent twice before a flush overwrites the pending entry rather than
|
|
220
|
+
queuing a second one -- registration is idempotent server-side, so
|
|
221
|
+
only the latest values for a given agent ever need to reach the
|
|
222
|
+
server.
|
|
223
|
+
"""
|
|
169
224
|
try:
|
|
170
225
|
payload = AgentRegistration(
|
|
171
226
|
external_id=external_id,
|
|
@@ -178,10 +233,17 @@ class AgentGovernClient:
|
|
|
178
233
|
tags=tags or [],
|
|
179
234
|
metadata=metadata or {},
|
|
180
235
|
)
|
|
181
|
-
if
|
|
182
|
-
|
|
236
|
+
if external_id not in self._reg_buffer and len(self._reg_buffer) >= _REG_BUFFER_MAXLEN:
|
|
237
|
+
# Generous cap on DISTINCT external_ids, not reachable by
|
|
238
|
+
# looping the same agent (that dedupes above instead).
|
|
239
|
+
# Drop the oldest-registered distinct agent -- correctness
|
|
240
|
+
# doesn't depend on which one, since both ingest routes'
|
|
241
|
+
# upsert logic already makes final agent state
|
|
242
|
+
# order-independent.
|
|
243
|
+
self._reg_buffer.popitem(last=False)
|
|
244
|
+
self._reg_buffer[external_id] = payload
|
|
183
245
|
except Exception: # noqa: BLE001
|
|
184
|
-
pass
|
|
246
|
+
pass # fail-open always -- payload construction errors only
|
|
185
247
|
|
|
186
248
|
def heartbeat(self) -> dict[str, Any]:
|
|
187
249
|
"""
|
|
@@ -343,6 +405,8 @@ class AgentGovernClient:
|
|
|
343
405
|
"policy_engine_url": self.policy_engine_url,
|
|
344
406
|
"buffer_size": len(self._buffer),
|
|
345
407
|
"consecutive_failures": self._consecutive_failures,
|
|
408
|
+
"reg_buffer_size": len(self._reg_buffer),
|
|
409
|
+
"reg_consecutive_failures": self._reg_consecutive_failures,
|
|
346
410
|
}
|
|
347
411
|
|
|
348
412
|
def shutdown(self, timeout: float = 5.0) -> None:
|
|
@@ -377,7 +441,19 @@ class AgentGovernClient:
|
|
|
377
441
|
asyncio.run_coroutine_threadsafe(self._flush(), self._loop)
|
|
378
442
|
|
|
379
443
|
async def _flush(self) -> None:
|
|
380
|
-
"""
|
|
444
|
+
"""
|
|
445
|
+
Drain the registration buffer, then the action buffer, and POST to
|
|
446
|
+
the ingest endpoints.
|
|
447
|
+
|
|
448
|
+
Registrations are drained first -- not because arrival order matters
|
|
449
|
+
at the server (it doesn't: both ingest routes' upsert logic makes
|
|
450
|
+
final agent state order-independent regardless of which request
|
|
451
|
+
lands first), but so a registration and the actions that immediately
|
|
452
|
+
follow it are handled in the same tick rather than one waiting an
|
|
453
|
+
extra flush cycle behind the other.
|
|
454
|
+
"""
|
|
455
|
+
await self._flush_registrations()
|
|
456
|
+
|
|
381
457
|
if not self._buffer:
|
|
382
458
|
return
|
|
383
459
|
|
|
@@ -434,6 +510,95 @@ class AgentGovernClient:
|
|
|
434
510
|
await asyncio.sleep(min(delay, _BACKOFF_MAX))
|
|
435
511
|
delay = min(delay * 2, _BACKOFF_MAX)
|
|
436
512
|
|
|
513
|
+
async def _flush_registrations(self) -> None:
|
|
514
|
+
"""
|
|
515
|
+
Drain and POST up to `_REG_BATCH_SIZE` pending registrations, one at
|
|
516
|
+
a time, with the same bounded retry+backoff `_flush()` already uses
|
|
517
|
+
for actions.
|
|
518
|
+
|
|
519
|
+
STORY-109 (b): fail-open here means this never raises into the
|
|
520
|
+
caller and never blocks actions from flushing behind a stuck
|
|
521
|
+
registration -- the customer's agent is never broken by a
|
|
522
|
+
registration failure, full stop. It does NOT mean failures are
|
|
523
|
+
invisible: after `_WARN_AFTER_FAILURES` consecutive failures we
|
|
524
|
+
print to stderr, the exact observability precedent `_flush()`
|
|
525
|
+
already sets for actions below. Telling the operator something
|
|
526
|
+
failed is not a fail-open violation; silently discarding it with no
|
|
527
|
+
signal anywhere is what made this invisible for three months.
|
|
528
|
+
|
|
529
|
+
STORY-109 (security review): per-tick cap matches `self.batch_size`'s
|
|
530
|
+
own discipline for actions -- an outage plus many queued
|
|
531
|
+
registrations (each up to 3 retries with backoff) must not starve
|
|
532
|
+
action ingestion behind this call. Actions are the higher-volume
|
|
533
|
+
path that matters more to a customer; registration can wait a tick,
|
|
534
|
+
telemetry backing up is worse. Any registrations left over after
|
|
535
|
+
`_REG_BATCH_SIZE` simply wait for the next flush tick, same as an
|
|
536
|
+
oversized action buffer already does for `self._buffer`.
|
|
537
|
+
"""
|
|
538
|
+
processed = 0
|
|
539
|
+
while self._reg_buffer and processed < _REG_BATCH_SIZE:
|
|
540
|
+
processed += 1
|
|
541
|
+
try:
|
|
542
|
+
_, payload = self._reg_buffer.popitem(last=False)
|
|
543
|
+
except KeyError:
|
|
544
|
+
# Defensive, not currently reachable: self._reg_buffer has
|
|
545
|
+
# exactly one producer (register_agent(), append/update
|
|
546
|
+
# only) and one consumer (this method, called serially from
|
|
547
|
+
# the background thread) -- the `while self._reg_buffer:`
|
|
548
|
+
# check can't go stale before this pop under that model.
|
|
549
|
+
# Kept anyway, matching _flush()'s own guard around the
|
|
550
|
+
# analogous self._buffer.popleft() for the same reason that
|
|
551
|
+
# guard exists there: if a future concurrency change ever
|
|
552
|
+
# adds a second consumer, "unreachable" stops being true,
|
|
553
|
+
# and removing this as dead code is exactly how that gap
|
|
554
|
+
# would get silently reintroduced. Do not remove.
|
|
555
|
+
break
|
|
556
|
+
delay = _BACKOFF_BASE
|
|
557
|
+
for attempt in range(1, 4): # up to 3 attempts per registration
|
|
558
|
+
try:
|
|
559
|
+
await self._post_register(payload)
|
|
560
|
+
self._reg_consecutive_failures = 0
|
|
561
|
+
break
|
|
562
|
+
except httpx.HTTPStatusError as exc:
|
|
563
|
+
if exc.response.status_code == 429:
|
|
564
|
+
# Rate limited -- drop this registration, no retry,
|
|
565
|
+
# no failure counter increment. Same reasoning as
|
|
566
|
+
# _flush()'s own 429 handling: retrying would
|
|
567
|
+
# amplify load on the rate limiter.
|
|
568
|
+
retry_after = exc.response.headers.get("Retry-After", "unknown")
|
|
569
|
+
print(
|
|
570
|
+
f"[agentgovern] WARNING: registration rate limit reached "
|
|
571
|
+
f"(HTTP 429) for agent '{payload.external_id}'. Registration "
|
|
572
|
+
f"dropped. Retry-After: {retry_after}s.",
|
|
573
|
+
file=sys.stderr,
|
|
574
|
+
)
|
|
575
|
+
break
|
|
576
|
+
self._reg_consecutive_failures += 1
|
|
577
|
+
if self._reg_consecutive_failures >= _WARN_AFTER_FAILURES:
|
|
578
|
+
print(
|
|
579
|
+
f"[agentgovern] WARNING: {self._reg_consecutive_failures} "
|
|
580
|
+
f"consecutive registration failures (HTTP "
|
|
581
|
+
f"{exc.response.status_code}: {exc.response.text[:200]}) for "
|
|
582
|
+
f"agent '{payload.external_id}'. Registration may be lost.",
|
|
583
|
+
file=sys.stderr,
|
|
584
|
+
)
|
|
585
|
+
if attempt < 3:
|
|
586
|
+
await asyncio.sleep(min(delay, _BACKOFF_MAX))
|
|
587
|
+
delay = min(delay * 2, _BACKOFF_MAX)
|
|
588
|
+
except Exception as exc: # noqa: BLE001
|
|
589
|
+
self._reg_consecutive_failures += 1
|
|
590
|
+
if self._reg_consecutive_failures >= _WARN_AFTER_FAILURES:
|
|
591
|
+
print(
|
|
592
|
+
f"[agentgovern] WARNING: {self._reg_consecutive_failures} "
|
|
593
|
+
f"consecutive registration failures ({type(exc).__name__}: "
|
|
594
|
+
f"{exc}) for agent '{payload.external_id}'. Registration may "
|
|
595
|
+
"be lost. Check your API key and network connectivity.",
|
|
596
|
+
file=sys.stderr,
|
|
597
|
+
)
|
|
598
|
+
if attempt < 3:
|
|
599
|
+
await asyncio.sleep(min(delay, _BACKOFF_MAX))
|
|
600
|
+
delay = min(delay * 2, _BACKOFF_MAX)
|
|
601
|
+
|
|
437
602
|
async def _post_batch(self, batch: list[ActionRecord]) -> None:
|
|
438
603
|
actions = []
|
|
439
604
|
for record in batch:
|
|
@@ -477,16 +642,31 @@ class AgentGovernClient:
|
|
|
477
642
|
resp.raise_for_status()
|
|
478
643
|
|
|
479
644
|
async def _post_register(self, payload: AgentRegistration) -> None:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
645
|
+
"""
|
|
646
|
+
Single POST attempt -- no retry, no exception handling. Raises on
|
|
647
|
+
failure; `_flush_registrations()` owns retry/backoff/warning, the
|
|
648
|
+
same split `_post_batch()`/`_flush()` already use for actions.
|
|
649
|
+
|
|
650
|
+
Null-strips before serializing (matching `_post_batch`'s own
|
|
651
|
+
step-5 precedent, commit 30efec5, 2026-04-19): Pydantic v2's
|
|
652
|
+
`model_dump_json()` sends unset optional fields as explicit JSON
|
|
653
|
+
`null`, which the server's Zod `.optional()` fields reject as a
|
|
654
|
+
distinct value from absent-key `undefined`. The server-side fix
|
|
655
|
+
(NAGT-185, `registerSchema`'s `.nullable()`) makes this currently
|
|
656
|
+
harmless either way, but stripping here closes the gap at its
|
|
657
|
+
source rather than relying solely on the server tolerating it --
|
|
658
|
+
the exact generalization NAGT-185's own audit named and deferred.
|
|
659
|
+
"""
|
|
660
|
+
d = payload.model_dump(mode="json")
|
|
661
|
+
d = {k: v for k, v in d.items() if v is not None}
|
|
662
|
+
body = json.dumps(d)
|
|
663
|
+
async with httpx.AsyncClient(headers=self._auth_headers(), timeout=10.0) as client:
|
|
664
|
+
resp = await client.post(
|
|
665
|
+
f"{self.base_url}/api/v1/ingest/agents/register",
|
|
666
|
+
content=body,
|
|
667
|
+
headers={"Content-Type": "application/json"},
|
|
668
|
+
)
|
|
669
|
+
resp.raise_for_status()
|
|
490
670
|
|
|
491
671
|
def _auth_headers(self) -> dict[str, str]:
|
|
492
672
|
return {
|