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.
Files changed (65) hide show
  1. {agentgovern-2.2.0 → agentgovern-2.2.1}/CHANGELOG.md +41 -0
  2. {agentgovern-2.2.0 → agentgovern-2.2.1}/PKG-INFO +1 -1
  3. agentgovern-2.2.1/agentgovern/_version.py +1 -0
  4. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/client.py +195 -15
  5. {agentgovern-2.2.0 → agentgovern-2.2.1}/pyproject.toml +1 -1
  6. agentgovern-2.2.1/tests/test_client.py +512 -0
  7. agentgovern-2.2.1/tests/test_register_contract.py +120 -0
  8. agentgovern-2.2.0/agentgovern/_version.py +0 -1
  9. agentgovern-2.2.0/tests/test_client.py +0 -210
  10. {agentgovern-2.2.0 → agentgovern-2.2.1}/.gitignore +0 -0
  11. {agentgovern-2.2.0 → agentgovern-2.2.1}/LICENSE +0 -0
  12. {agentgovern-2.2.0 → agentgovern-2.2.1}/README.md +0 -0
  13. {agentgovern-2.2.0 → agentgovern-2.2.1}/RELEASING.md +0 -0
  14. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/__init__.py +0 -0
  15. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/__main__.py +0 -0
  16. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/exceptions.py +0 -0
  17. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/gate2.py +0 -0
  18. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/__init__.py +0 -0
  19. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/cli.py +0 -0
  20. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/data/__init__.py +0 -0
  21. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/data/hook.py +0 -0
  22. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/drift.py +0 -0
  23. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/installer.py +0 -0
  24. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/hook/settings.py +0 -0
  25. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/__init__.py +0 -0
  26. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/_base.py +0 -0
  27. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/langchain.py +0 -0
  28. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/instrumentors/langgraph.py +0 -0
  29. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentgovern/types.py +0 -0
  30. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/__init__.py +0 -0
  31. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/client.py +0 -0
  32. {agentgovern-2.2.0 → agentgovern-2.2.1}/agentshield/types.py +0 -0
  33. {agentgovern-2.2.0 → agentgovern-2.2.1}/examples/langgraph_credit_decision_agent.py +0 -0
  34. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/__init__.py +0 -0
  35. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/__init__.py +0 -0
  36. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/conftest.py +0 -0
  37. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_bundled_hook.py +0 -0
  38. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_drift.py +0 -0
  39. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_context_snapshot.py +0 -0
  40. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_dispatch.py +0 -0
  41. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_outbox_queue.py +0 -0
  42. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_payload_extraction.py +0 -0
  43. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_payloads.py +0 -0
  44. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_remediation_message.py +0 -0
  45. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_timing_log.py +0 -0
  46. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_transcript_reading.py +0 -0
  47. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_turn_index.py +0 -0
  48. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_hook_v2_warn_verdict.py +0 -0
  49. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_installer.py +0 -0
  50. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/hook/test_settings.py +0 -0
  51. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_base_instrumentor.py +0 -0
  52. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_evaluate_input.py +0 -0
  53. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_full_path.py +0 -0
  54. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_latency.py +0 -0
  55. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_gate2_verdict_handler.py +0 -0
  56. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_instrument_langchain.py +0 -0
  57. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain.py +0 -0
  58. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain_gate1.py +0 -0
  59. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langchain_gate2_callbacks.py +0 -0
  60. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_langgraph.py +0 -0
  61. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_output_capture_contract.py +0 -0
  62. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_payload_schema.py +0 -0
  63. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_rate_limit_handling.py +0 -0
  64. {agentgovern-2.2.0 → agentgovern-2.2.1}/tests/test_timestamp.py +0 -0
  65. {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.0
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
- """Fire-and-forget agent registration. Never raises."""
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 self._loop is not None and self._loop.is_running():
182
- asyncio.run_coroutine_threadsafe(self._post_register(payload), self._loop)
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
- """Drain the buffer and POST to the ingest endpoint."""
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
- try:
481
- async with httpx.AsyncClient(headers=self._auth_headers(), timeout=10.0) as client:
482
- resp = await client.post(
483
- f"{self.base_url}/api/v1/ingest/agents/register",
484
- content=payload.model_dump_json(),
485
- headers={"Content-Type": "application/json"},
486
- )
487
- resp.raise_for_status()
488
- except Exception: # noqa: BLE001
489
- pass
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 {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentgovern"
3
- version = "2.2.0"
3
+ version = "2.2.1"
4
4
  description = "Compliance-as-code middleware for agentic AI workflows."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }