agent_os_kernel 3.1.0__py3-none-any.whl
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_control_plane/__init__.py +662 -0
- agent_control_plane/a2a_adapter.py +543 -0
- agent_control_plane/adapter.py +417 -0
- agent_control_plane/agent_hibernation.py +394 -0
- agent_control_plane/agent_kernel.py +470 -0
- agent_control_plane/compliance.py +720 -0
- agent_control_plane/constraint_graphs.py +478 -0
- agent_control_plane/control_plane.py +854 -0
- agent_control_plane/example_executors.py +195 -0
- agent_control_plane/execution_engine.py +231 -0
- agent_control_plane/flight_recorder.py +846 -0
- agent_control_plane/governance_layer.py +435 -0
- agent_control_plane/hf_utils.py +563 -0
- agent_control_plane/interfaces/__init__.py +55 -0
- agent_control_plane/interfaces/kernel_interface.py +361 -0
- agent_control_plane/interfaces/plugin_interface.py +497 -0
- agent_control_plane/interfaces/protocol_interfaces.py +387 -0
- agent_control_plane/kernel_space.py +1009 -0
- agent_control_plane/langchain_adapter.py +424 -0
- agent_control_plane/lifecycle.py +3113 -0
- agent_control_plane/mcp_adapter.py +653 -0
- agent_control_plane/ml_safety.py +563 -0
- agent_control_plane/multimodal.py +727 -0
- agent_control_plane/mute_agent.py +422 -0
- agent_control_plane/observability.py +787 -0
- agent_control_plane/orchestrator.py +482 -0
- agent_control_plane/plugin_registry.py +750 -0
- agent_control_plane/policy_engine.py +954 -0
- agent_control_plane/process_isolation.py +777 -0
- agent_control_plane/shadow_mode.py +310 -0
- agent_control_plane/signals.py +493 -0
- agent_control_plane/supervisor_agents.py +430 -0
- agent_control_plane/time_travel_debugger.py +557 -0
- agent_control_plane/tool_registry.py +452 -0
- agent_control_plane/vfs.py +697 -0
- agent_kernel/__init__.py +69 -0
- agent_kernel/analyzer.py +435 -0
- agent_kernel/auditor.py +36 -0
- agent_kernel/completeness_auditor.py +237 -0
- agent_kernel/detector.py +203 -0
- agent_kernel/kernel.py +744 -0
- agent_kernel/memory_manager.py +85 -0
- agent_kernel/models.py +374 -0
- agent_kernel/nudge_mechanism.py +263 -0
- agent_kernel/outcome_analyzer.py +338 -0
- agent_kernel/patcher.py +582 -0
- agent_kernel/semantic_analyzer.py +316 -0
- agent_kernel/semantic_purge.py +349 -0
- agent_kernel/simulator.py +449 -0
- agent_kernel/teacher.py +85 -0
- agent_kernel/triage.py +152 -0
- agent_os/__init__.py +409 -0
- agent_os/_adversarial_impl.py +200 -0
- agent_os/_circuit_breaker_impl.py +232 -0
- agent_os/_mcp_metrics.py +193 -0
- agent_os/adversarial.py +20 -0
- agent_os/agents_compat.py +490 -0
- agent_os/audit_logger.py +135 -0
- agent_os/base_agent.py +651 -0
- agent_os/circuit_breaker.py +34 -0
- agent_os/cli/__init__.py +659 -0
- agent_os/cli/cmd_audit.py +128 -0
- agent_os/cli/cmd_init.py +152 -0
- agent_os/cli/cmd_policy.py +41 -0
- agent_os/cli/cmd_policy_gen.py +180 -0
- agent_os/cli/cmd_validate.py +258 -0
- agent_os/cli/mcp_scan.py +265 -0
- agent_os/cli/output.py +192 -0
- agent_os/cli/policy_checker.py +330 -0
- agent_os/compat.py +74 -0
- agent_os/constraint_graph.py +234 -0
- agent_os/content_governance.py +140 -0
- agent_os/context_budget.py +305 -0
- agent_os/credential_redactor.py +224 -0
- agent_os/diff_policy.py +89 -0
- agent_os/egress_policy.py +159 -0
- agent_os/escalation.py +276 -0
- agent_os/event_bus.py +124 -0
- agent_os/exceptions.py +180 -0
- agent_os/execution_context_policy.py +141 -0
- agent_os/github_enterprise.py +96 -0
- agent_os/health.py +20 -0
- agent_os/integrations/__init__.py +279 -0
- agent_os/integrations/a2a_adapter.py +279 -0
- agent_os/integrations/agent_lightning/__init__.py +30 -0
- agent_os/integrations/anthropic_adapter.py +420 -0
- agent_os/integrations/autogen_adapter.py +620 -0
- agent_os/integrations/base.py +1137 -0
- agent_os/integrations/compat.py +229 -0
- agent_os/integrations/config.py +98 -0
- agent_os/integrations/conversation_guardian.py +957 -0
- agent_os/integrations/crewai_adapter.py +467 -0
- agent_os/integrations/drift_detector.py +425 -0
- agent_os/integrations/dry_run.py +124 -0
- agent_os/integrations/escalation.py +582 -0
- agent_os/integrations/gemini_adapter.py +364 -0
- agent_os/integrations/google_adk_adapter.py +633 -0
- agent_os/integrations/guardrails_adapter.py +394 -0
- agent_os/integrations/health.py +197 -0
- agent_os/integrations/langchain_adapter.py +654 -0
- agent_os/integrations/llamafirewall.py +343 -0
- agent_os/integrations/llamaindex_adapter.py +188 -0
- agent_os/integrations/logging.py +191 -0
- agent_os/integrations/maf_adapter.py +631 -0
- agent_os/integrations/mistral_adapter.py +365 -0
- agent_os/integrations/openai_adapter.py +816 -0
- agent_os/integrations/openai_agents_sdk.py +406 -0
- agent_os/integrations/policy_compose.py +171 -0
- agent_os/integrations/profiling.py +144 -0
- agent_os/integrations/pydantic_ai_adapter.py +420 -0
- agent_os/integrations/rate_limiter.py +130 -0
- agent_os/integrations/rbac.py +143 -0
- agent_os/integrations/registry.py +113 -0
- agent_os/integrations/scope_guard.py +303 -0
- agent_os/integrations/semantic_kernel_adapter.py +769 -0
- agent_os/integrations/smolagents_adapter.py +629 -0
- agent_os/integrations/templates.py +178 -0
- agent_os/integrations/token_budget.py +134 -0
- agent_os/integrations/tool_aliases.py +190 -0
- agent_os/integrations/webhooks.py +177 -0
- agent_os/lite.py +208 -0
- agent_os/mcp_gateway.py +385 -0
- agent_os/mcp_message_signer.py +273 -0
- agent_os/mcp_protocols.py +161 -0
- agent_os/mcp_response_scanner.py +232 -0
- agent_os/mcp_security.py +924 -0
- agent_os/mcp_session_auth.py +231 -0
- agent_os/mcp_sliding_rate_limiter.py +184 -0
- agent_os/memory_guard.py +409 -0
- agent_os/metrics.py +134 -0
- agent_os/mute.py +428 -0
- agent_os/mute_agent.py +209 -0
- agent_os/policies/__init__.py +77 -0
- agent_os/policies/async_evaluator.py +275 -0
- agent_os/policies/backends.py +670 -0
- agent_os/policies/bridge.py +169 -0
- agent_os/policies/budget.py +85 -0
- agent_os/policies/cli.py +294 -0
- agent_os/policies/conflict_resolution.py +270 -0
- agent_os/policies/data_classification.py +252 -0
- agent_os/policies/evaluator.py +239 -0
- agent_os/policies/policy_schema.json +228 -0
- agent_os/policies/rate_limiting.py +145 -0
- agent_os/policies/schema.py +115 -0
- agent_os/policies/shared.py +331 -0
- agent_os/prompt_injection.py +694 -0
- agent_os/providers.py +182 -0
- agent_os/py.typed +0 -0
- agent_os/retry.py +81 -0
- agent_os/reversibility.py +251 -0
- agent_os/sandbox.py +432 -0
- agent_os/sandbox_provider.py +140 -0
- agent_os/secure_codegen.py +525 -0
- agent_os/security_skills.py +538 -0
- agent_os/semantic_policy.py +422 -0
- agent_os/server/__init__.py +15 -0
- agent_os/server/__main__.py +25 -0
- agent_os/server/app.py +277 -0
- agent_os/server/models.py +104 -0
- agent_os/shift_left_metrics.py +130 -0
- agent_os/stateless.py +742 -0
- agent_os/supervisor.py +148 -0
- agent_os/task_outcome.py +148 -0
- agent_os/transparency.py +181 -0
- agent_os/trust_root.py +128 -0
- agent_os_kernel-3.1.0.dist-info/METADATA +1269 -0
- agent_os_kernel-3.1.0.dist-info/RECORD +337 -0
- agent_os_kernel-3.1.0.dist-info/WHEEL +4 -0
- agent_os_kernel-3.1.0.dist-info/entry_points.txt +2 -0
- agent_os_kernel-3.1.0.dist-info/licenses/LICENSE +21 -0
- agent_os_observability/__init__.py +27 -0
- agent_os_observability/dashboards.py +898 -0
- agent_os_observability/metrics.py +398 -0
- agent_os_observability/server.py +223 -0
- agent_os_observability/tracer.py +232 -0
- agent_primitives/__init__.py +24 -0
- agent_primitives/failures.py +84 -0
- agent_primitives/py.typed +0 -0
- amb_core/__init__.py +177 -0
- amb_core/adapters/__init__.py +57 -0
- amb_core/adapters/aws_sqs_broker.py +376 -0
- amb_core/adapters/azure_servicebus_broker.py +340 -0
- amb_core/adapters/kafka_broker.py +260 -0
- amb_core/adapters/nats_broker.py +285 -0
- amb_core/adapters/rabbitmq_broker.py +235 -0
- amb_core/adapters/redis_broker.py +262 -0
- amb_core/broker.py +145 -0
- amb_core/bus.py +481 -0
- amb_core/cloudevents.py +509 -0
- amb_core/dlq.py +345 -0
- amb_core/hf_utils.py +536 -0
- amb_core/memory_broker.py +410 -0
- amb_core/models.py +141 -0
- amb_core/persistence.py +529 -0
- amb_core/schema.py +294 -0
- amb_core/tracing.py +358 -0
- atr/__init__.py +640 -0
- atr/access.py +348 -0
- atr/composition.py +645 -0
- atr/decorator.py +357 -0
- atr/executor.py +384 -0
- atr/health.py +557 -0
- atr/hf_utils.py +449 -0
- atr/injection.py +422 -0
- atr/metrics.py +440 -0
- atr/policies.py +403 -0
- atr/py.typed +2 -0
- atr/registry.py +452 -0
- atr/schema.py +480 -0
- atr/tools/safe/__init__.py +75 -0
- atr/tools/safe/calculator.py +467 -0
- atr/tools/safe/datetime_tool.py +443 -0
- atr/tools/safe/file_reader.py +402 -0
- atr/tools/safe/http_client.py +316 -0
- atr/tools/safe/json_parser.py +374 -0
- atr/tools/safe/text_tool.py +537 -0
- atr/tools/safe/toolkit.py +175 -0
- caas/__init__.py +162 -0
- caas/api/__init__.py +7 -0
- caas/api/server.py +1328 -0
- caas/caching.py +834 -0
- caas/cli.py +210 -0
- caas/conversation.py +223 -0
- caas/decay.py +72 -0
- caas/detection/__init__.py +9 -0
- caas/detection/detector.py +238 -0
- caas/enrichment.py +130 -0
- caas/gateway/__init__.py +27 -0
- caas/gateway/trust_gateway.py +474 -0
- caas/hf_utils.py +479 -0
- caas/ingestion/__init__.py +23 -0
- caas/ingestion/processors.py +253 -0
- caas/ingestion/structure_parser.py +188 -0
- caas/models.py +356 -0
- caas/pragmatic_truth.py +444 -0
- caas/routing/__init__.py +10 -0
- caas/routing/heuristic_router.py +58 -0
- caas/storage/__init__.py +9 -0
- caas/storage/store.py +389 -0
- caas/triad.py +213 -0
- caas/tuning/__init__.py +9 -0
- caas/tuning/tuner.py +329 -0
- caas/vfs/__init__.py +14 -0
- caas/vfs/filesystem.py +452 -0
- cmvk/__init__.py +218 -0
- cmvk/audit.py +402 -0
- cmvk/benchmarks.py +478 -0
- cmvk/constitutional.py +904 -0
- cmvk/hf_utils.py +301 -0
- cmvk/metrics.py +473 -0
- cmvk/profiles.py +300 -0
- cmvk/py.typed +0 -0
- cmvk/types.py +12 -0
- cmvk/verification.py +956 -0
- emk/__init__.py +89 -0
- emk/causal.py +352 -0
- emk/hf_utils.py +421 -0
- emk/indexer.py +83 -0
- emk/py.typed +0 -0
- emk/schema.py +204 -0
- emk/sleep_cycle.py +347 -0
- emk/store.py +281 -0
- iatp/__init__.py +166 -0
- iatp/attestation.py +461 -0
- iatp/cli.py +317 -0
- iatp/hf_utils.py +472 -0
- iatp/ipc_pipes.py +580 -0
- iatp/main.py +412 -0
- iatp/models/__init__.py +447 -0
- iatp/policy_engine.py +337 -0
- iatp/py.typed +2 -0
- iatp/recovery.py +321 -0
- iatp/security/__init__.py +270 -0
- iatp/sidecar/__init__.py +519 -0
- iatp/telemetry/__init__.py +164 -0
- iatp/tests/__init__.py +1 -0
- iatp/tests/test_attestation.py +370 -0
- iatp/tests/test_cli.py +131 -0
- iatp/tests/test_ed25519_attestation.py +211 -0
- iatp/tests/test_models.py +130 -0
- iatp/tests/test_policy_engine.py +347 -0
- iatp/tests/test_recovery.py +281 -0
- iatp/tests/test_security.py +222 -0
- iatp/tests/test_sidecar.py +167 -0
- iatp/tests/test_telemetry.py +175 -0
- mcp_kernel_server/__init__.py +28 -0
- mcp_kernel_server/cli.py +274 -0
- mcp_kernel_server/resources.py +217 -0
- mcp_kernel_server/server.py +564 -0
- mcp_kernel_server/tools.py +1174 -0
- mute_agent/__init__.py +68 -0
- mute_agent/core/__init__.py +1 -0
- mute_agent/core/execution_agent.py +166 -0
- mute_agent/core/handshake_protocol.py +201 -0
- mute_agent/core/reasoning_agent.py +238 -0
- mute_agent/knowledge_graph/__init__.py +1 -0
- mute_agent/knowledge_graph/graph_elements.py +65 -0
- mute_agent/knowledge_graph/multidimensional_graph.py +170 -0
- mute_agent/knowledge_graph/subgraph.py +224 -0
- mute_agent/listener/__init__.py +43 -0
- mute_agent/listener/adapters/__init__.py +31 -0
- mute_agent/listener/adapters/base_adapter.py +189 -0
- mute_agent/listener/adapters/caas_adapter.py +344 -0
- mute_agent/listener/adapters/control_plane_adapter.py +436 -0
- mute_agent/listener/adapters/iatp_adapter.py +332 -0
- mute_agent/listener/adapters/scak_adapter.py +251 -0
- mute_agent/listener/listener.py +610 -0
- mute_agent/listener/state_observer.py +436 -0
- mute_agent/listener/threshold_config.py +313 -0
- mute_agent/super_system/__init__.py +1 -0
- mute_agent/super_system/router.py +204 -0
- mute_agent/visualization/__init__.py +10 -0
- mute_agent/visualization/graph_debugger.py +502 -0
- nexus/README.md +60 -0
- nexus/__init__.py +51 -0
- nexus/arbiter.py +359 -0
- nexus/client.py +466 -0
- nexus/dmz.py +444 -0
- nexus/escrow.py +430 -0
- nexus/exceptions.py +286 -0
- nexus/pyproject.toml +36 -0
- nexus/registry.py +393 -0
- nexus/reputation.py +425 -0
- nexus/schemas/__init__.py +51 -0
- nexus/schemas/compliance.py +276 -0
- nexus/schemas/escrow.py +251 -0
- nexus/schemas/manifest.py +225 -0
- nexus/schemas/receipt.py +208 -0
- nexus/tests/__init__.py +0 -0
- nexus/tests/conftest.py +146 -0
- nexus/tests/test_arbiter.py +192 -0
- nexus/tests/test_dmz.py +194 -0
- nexus/tests/test_escrow.py +276 -0
- nexus/tests/test_exceptions.py +225 -0
- nexus/tests/test_registry.py +232 -0
- nexus/tests/test_reputation.py +328 -0
- nexus/tests/test_schemas.py +295 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""
|
|
4
|
+
Performance profiling for governance checks and adapter operations.
|
|
5
|
+
|
|
6
|
+
Provides a decorator and context manager for measuring execution time,
|
|
7
|
+
call counts, and memory usage of adapter methods.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import time
|
|
12
|
+
import tracemalloc
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MethodStats:
|
|
19
|
+
"""Statistics for a single profiled method."""
|
|
20
|
+
name: str
|
|
21
|
+
call_count: int = 0
|
|
22
|
+
total_time_ms: float = 0.0
|
|
23
|
+
min_time_ms: float = float("inf")
|
|
24
|
+
max_time_ms: float = 0.0
|
|
25
|
+
total_memory_delta: int = 0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def avg_time_ms(self) -> float:
|
|
29
|
+
return self.total_time_ms / self.call_count if self.call_count else 0.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ProfilingReport:
|
|
34
|
+
"""Aggregated profiling results."""
|
|
35
|
+
methods: dict[str, MethodStats] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def total_calls(self) -> int:
|
|
39
|
+
return sum(m.call_count for m in self.methods.values())
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_time_ms(self) -> float:
|
|
43
|
+
return sum(m.total_time_ms for m in self.methods.values())
|
|
44
|
+
|
|
45
|
+
def format_report(self) -> str:
|
|
46
|
+
"""Return a human-readable table of profiling results."""
|
|
47
|
+
if not self.methods:
|
|
48
|
+
return "No profiling data collected."
|
|
49
|
+
header = f"{'Method':<30} {'Calls':>6} {'Total ms':>10} {'Avg ms':>10} {'Min ms':>10} {'Max ms':>10}"
|
|
50
|
+
sep = "-" * len(header)
|
|
51
|
+
lines = [header, sep]
|
|
52
|
+
for stats in sorted(self.methods.values(), key=lambda s: s.total_time_ms, reverse=True):
|
|
53
|
+
lines.append(
|
|
54
|
+
f"{stats.name:<30} {stats.call_count:>6} "
|
|
55
|
+
f"{stats.total_time_ms:>10.2f} {stats.avg_time_ms:>10.2f} "
|
|
56
|
+
f"{stats.min_time_ms:>10.2f} {stats.max_time_ms:>10.2f}"
|
|
57
|
+
)
|
|
58
|
+
lines.append(sep)
|
|
59
|
+
lines.append(f"{'TOTAL':<30} {self.total_calls:>6} {self.total_time_ms:>10.2f}")
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Global report used by the decorator
|
|
64
|
+
_global_report = ProfilingReport()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_report() -> ProfilingReport:
|
|
68
|
+
"""Retrieve the global profiling report."""
|
|
69
|
+
return _global_report
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def reset_report() -> None:
|
|
73
|
+
"""Reset the global profiling report."""
|
|
74
|
+
_global_report.methods.clear()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def profile_governance(func: Optional[Callable] = None, *, track_memory: bool = False):
|
|
78
|
+
"""Decorator that profiles execution time (and optionally memory) of a method.
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
@profile_governance
|
|
82
|
+
def my_method(self, ...): ...
|
|
83
|
+
|
|
84
|
+
@profile_governance(track_memory=True)
|
|
85
|
+
def my_method(self, ...): ...
|
|
86
|
+
"""
|
|
87
|
+
def decorator(fn: Callable) -> Callable:
|
|
88
|
+
@functools.wraps(fn)
|
|
89
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
90
|
+
key = fn.__name__
|
|
91
|
+
if key not in _global_report.methods:
|
|
92
|
+
_global_report.methods[key] = MethodStats(name=key)
|
|
93
|
+
stats = _global_report.methods[key]
|
|
94
|
+
|
|
95
|
+
mem_before = 0
|
|
96
|
+
if track_memory:
|
|
97
|
+
if not tracemalloc.is_tracing():
|
|
98
|
+
tracemalloc.start()
|
|
99
|
+
_, mem_before = tracemalloc.get_traced_memory()
|
|
100
|
+
|
|
101
|
+
start = time.perf_counter()
|
|
102
|
+
try:
|
|
103
|
+
return fn(*args, **kwargs)
|
|
104
|
+
finally:
|
|
105
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
106
|
+
stats.call_count += 1
|
|
107
|
+
stats.total_time_ms += elapsed_ms
|
|
108
|
+
stats.min_time_ms = min(stats.min_time_ms, elapsed_ms)
|
|
109
|
+
stats.max_time_ms = max(stats.max_time_ms, elapsed_ms)
|
|
110
|
+
|
|
111
|
+
if track_memory:
|
|
112
|
+
_, mem_after = tracemalloc.get_traced_memory()
|
|
113
|
+
stats.total_memory_delta += mem_after - mem_before
|
|
114
|
+
|
|
115
|
+
return wrapper
|
|
116
|
+
|
|
117
|
+
if func is not None:
|
|
118
|
+
return decorator(func)
|
|
119
|
+
return decorator
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ProfileGovernanceContext:
|
|
123
|
+
"""Context manager for scoped profiling.
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
with ProfileGovernanceContext() as report:
|
|
127
|
+
# run profiled code
|
|
128
|
+
print(report.format_report())
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, track_memory: bool = False):
|
|
132
|
+
self.track_memory = track_memory
|
|
133
|
+
self.report = ProfilingReport()
|
|
134
|
+
self._previous_report: Optional[ProfilingReport] = None
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> ProfilingReport:
|
|
137
|
+
global _global_report
|
|
138
|
+
self._previous_report = _global_report
|
|
139
|
+
_global_report = self.report
|
|
140
|
+
return self.report
|
|
141
|
+
|
|
142
|
+
def __exit__(self, *exc: Any) -> None:
|
|
143
|
+
global _global_report
|
|
144
|
+
_global_report = self._previous_report # type: ignore
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""
|
|
4
|
+
PydanticAI Integration for Agent-OS
|
|
5
|
+
====================================
|
|
6
|
+
|
|
7
|
+
Provides kernel-level governance for PydanticAI agent workflows.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Policy enforcement for agent tool calls
|
|
11
|
+
- Tool call interception via PydanticAI's tool system
|
|
12
|
+
- Human approval workflows for sensitive operations
|
|
13
|
+
- Call budget enforcement (max_tool_calls)
|
|
14
|
+
- Audit logging for all tool executions
|
|
15
|
+
- Blocked pattern detection in tool arguments
|
|
16
|
+
- Graceful degradation when pydantic-ai is not installed
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> from agent_os.integrations.pydantic_ai_adapter import PydanticAIKernel
|
|
20
|
+
>>> from agent_os.integrations.base import GovernancePolicy
|
|
21
|
+
>>> from pydantic_ai import Agent
|
|
22
|
+
>>>
|
|
23
|
+
>>> policy = GovernancePolicy(
|
|
24
|
+
... max_tool_calls=10,
|
|
25
|
+
... allowed_tools=["search", "read_file"],
|
|
26
|
+
... blocked_patterns=["rm -rf", "DROP TABLE"],
|
|
27
|
+
... )
|
|
28
|
+
>>> kernel = PydanticAIKernel(policy=policy)
|
|
29
|
+
>>>
|
|
30
|
+
>>> agent = Agent("openai:gpt-4o", system_prompt="You are helpful.")
|
|
31
|
+
>>> governed = kernel.wrap(agent)
|
|
32
|
+
>>>
|
|
33
|
+
>>> result = await governed.run("Analyze this data")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from functools import wraps
|
|
42
|
+
from typing import Any, Callable
|
|
43
|
+
|
|
44
|
+
from .base import (
|
|
45
|
+
BaseIntegration,
|
|
46
|
+
ExecutionContext,
|
|
47
|
+
GovernancePolicy,
|
|
48
|
+
PolicyInterceptor,
|
|
49
|
+
PolicyViolationError,
|
|
50
|
+
ToolCallRequest,
|
|
51
|
+
ToolCallResult,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Graceful import handling for pydantic-ai
|
|
57
|
+
try:
|
|
58
|
+
import pydantic_ai # noqa: F401
|
|
59
|
+
HAS_PYDANTIC_AI = True
|
|
60
|
+
except ImportError:
|
|
61
|
+
HAS_PYDANTIC_AI = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HumanApprovalRequired(PolicyViolationError):
|
|
65
|
+
"""Raised when a tool call requires human approval."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, tool_name: str, arguments: dict[str, Any]):
|
|
68
|
+
self.tool_name = tool_name
|
|
69
|
+
self.arguments = arguments
|
|
70
|
+
super().__init__(
|
|
71
|
+
f"Tool '{tool_name}' requires human approval before execution"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PydanticAIKernel(BaseIntegration):
|
|
76
|
+
"""
|
|
77
|
+
PydanticAI adapter for Agent OS.
|
|
78
|
+
|
|
79
|
+
Supports:
|
|
80
|
+
- Agent wrapping with governance (run / run_sync)
|
|
81
|
+
- Individual tool call interception (allowed_tools, blocked_patterns)
|
|
82
|
+
- Human approval workflows for sensitive tools
|
|
83
|
+
- Call budget enforcement (max_tool_calls)
|
|
84
|
+
- Audit logging of all tool executions
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
policy: GovernancePolicy | None = None,
|
|
90
|
+
approval_callback: Callable[[str, dict[str, Any]], bool] | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
super().__init__(policy)
|
|
93
|
+
self._wrapped_agents: dict[int, Any] = {}
|
|
94
|
+
self._audit_log: list[dict[str, Any]] = []
|
|
95
|
+
self._approval_callback = approval_callback
|
|
96
|
+
self._start_time: float = time.monotonic()
|
|
97
|
+
self._last_error: str | None = None
|
|
98
|
+
logger.debug("PydanticAIKernel initialized with policy=%s", policy)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def audit_log(self) -> list[dict[str, Any]]:
|
|
102
|
+
"""Return the full audit log."""
|
|
103
|
+
return list(self._audit_log)
|
|
104
|
+
|
|
105
|
+
def _record_audit(
|
|
106
|
+
self,
|
|
107
|
+
event_type: str,
|
|
108
|
+
tool_name: str = "",
|
|
109
|
+
allowed: bool = True,
|
|
110
|
+
reason: str = "",
|
|
111
|
+
arguments: dict[str, Any] | None = None,
|
|
112
|
+
agent_id: str = "",
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Record an audit entry and return it."""
|
|
115
|
+
entry = {
|
|
116
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
117
|
+
"event_type": event_type,
|
|
118
|
+
"tool_name": tool_name,
|
|
119
|
+
"allowed": allowed,
|
|
120
|
+
"reason": reason,
|
|
121
|
+
"arguments": arguments or {},
|
|
122
|
+
"agent_id": agent_id,
|
|
123
|
+
}
|
|
124
|
+
if self.policy.log_all_calls:
|
|
125
|
+
self._audit_log.append(entry)
|
|
126
|
+
return entry
|
|
127
|
+
|
|
128
|
+
def wrap(self, agent: Any) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
Wrap a PydanticAI Agent with governance.
|
|
131
|
+
|
|
132
|
+
Intercepts:
|
|
133
|
+
- agent.run() / agent.run_sync()
|
|
134
|
+
- All registered tool calls
|
|
135
|
+
- Result validation
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
agent: A pydantic_ai.Agent instance (or mock).
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A governed wrapper around the agent.
|
|
142
|
+
"""
|
|
143
|
+
agent_id = getattr(agent, "name", None) or f"agent-{id(agent)}"
|
|
144
|
+
ctx = self.create_context(agent_id)
|
|
145
|
+
self._wrapped_agents[id(agent)] = agent
|
|
146
|
+
|
|
147
|
+
logger.info(
|
|
148
|
+
"Wrapping PydanticAI agent with governance: agent_id=%s", agent_id
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
original = agent
|
|
152
|
+
kernel = self
|
|
153
|
+
|
|
154
|
+
class GovernedPydanticAIAgent:
|
|
155
|
+
"""PydanticAI agent wrapped with Agent OS governance."""
|
|
156
|
+
|
|
157
|
+
def __init__(self_inner):
|
|
158
|
+
self_inner._original = original
|
|
159
|
+
self_inner._ctx = ctx
|
|
160
|
+
self_inner._kernel = kernel
|
|
161
|
+
self_inner._agent_id = agent_id
|
|
162
|
+
self_inner._wrap_tools()
|
|
163
|
+
|
|
164
|
+
def _wrap_tools(self_inner):
|
|
165
|
+
"""Intercept all tools registered on the agent."""
|
|
166
|
+
tools = _get_agent_tools(self_inner._original)
|
|
167
|
+
for tool_entry in tools:
|
|
168
|
+
_wrap_single_tool(tool_entry, self_inner, kernel, ctx)
|
|
169
|
+
|
|
170
|
+
async def run(self_inner, prompt: str, **kwargs) -> Any:
|
|
171
|
+
"""Governed async run."""
|
|
172
|
+
allowed, reason = kernel.pre_execute(ctx, prompt)
|
|
173
|
+
if not allowed:
|
|
174
|
+
kernel._last_error = reason
|
|
175
|
+
kernel._record_audit(
|
|
176
|
+
"run_blocked",
|
|
177
|
+
reason=reason or "",
|
|
178
|
+
agent_id=agent_id,
|
|
179
|
+
)
|
|
180
|
+
raise PolicyViolationError(reason or "Pre-execution check failed")
|
|
181
|
+
|
|
182
|
+
kernel._record_audit(
|
|
183
|
+
"run_start",
|
|
184
|
+
agent_id=agent_id,
|
|
185
|
+
reason=f"prompt_length={len(prompt)}",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
result = await self_inner._original.run(prompt, **kwargs)
|
|
190
|
+
kernel._record_audit("run_complete", agent_id=agent_id)
|
|
191
|
+
return result
|
|
192
|
+
except PolicyViolationError:
|
|
193
|
+
raise
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
kernel._last_error = str(exc)
|
|
196
|
+
kernel._record_audit(
|
|
197
|
+
"run_error",
|
|
198
|
+
agent_id=agent_id,
|
|
199
|
+
reason=str(exc),
|
|
200
|
+
allowed=False,
|
|
201
|
+
)
|
|
202
|
+
raise
|
|
203
|
+
|
|
204
|
+
def run_sync(self_inner, prompt: str, **kwargs) -> Any:
|
|
205
|
+
"""Governed sync run."""
|
|
206
|
+
allowed, reason = kernel.pre_execute(ctx, prompt)
|
|
207
|
+
if not allowed:
|
|
208
|
+
kernel._last_error = reason
|
|
209
|
+
kernel._record_audit(
|
|
210
|
+
"run_blocked",
|
|
211
|
+
reason=reason or "",
|
|
212
|
+
agent_id=agent_id,
|
|
213
|
+
)
|
|
214
|
+
raise PolicyViolationError(reason or "Pre-execution check failed")
|
|
215
|
+
|
|
216
|
+
kernel._record_audit(
|
|
217
|
+
"run_start",
|
|
218
|
+
agent_id=agent_id,
|
|
219
|
+
reason=f"prompt_length={len(prompt)}",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
result = self_inner._original.run_sync(prompt, **kwargs)
|
|
224
|
+
kernel._record_audit("run_complete", agent_id=agent_id)
|
|
225
|
+
return result
|
|
226
|
+
except PolicyViolationError:
|
|
227
|
+
raise
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
kernel._last_error = str(exc)
|
|
230
|
+
kernel._record_audit(
|
|
231
|
+
"run_error",
|
|
232
|
+
agent_id=agent_id,
|
|
233
|
+
reason=str(exc),
|
|
234
|
+
allowed=False,
|
|
235
|
+
)
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def original(self_inner) -> Any:
|
|
240
|
+
"""Return the original unwrapped agent before governance wrapping."""
|
|
241
|
+
return self_inner._original
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def context(self_inner) -> ExecutionContext:
|
|
245
|
+
"""Return the ExecutionContext tracking call counts and session state."""
|
|
246
|
+
return self_inner._ctx
|
|
247
|
+
|
|
248
|
+
def __getattr__(self_inner, name: str) -> Any:
|
|
249
|
+
return getattr(self_inner._original, name)
|
|
250
|
+
|
|
251
|
+
return GovernedPydanticAIAgent()
|
|
252
|
+
|
|
253
|
+
def unwrap(self, governed_agent: Any) -> Any:
|
|
254
|
+
"""Remove governance wrapper and return original agent."""
|
|
255
|
+
if hasattr(governed_agent, "_original"):
|
|
256
|
+
return governed_agent._original
|
|
257
|
+
return governed_agent
|
|
258
|
+
|
|
259
|
+
def intercept_tool_call(
|
|
260
|
+
self,
|
|
261
|
+
ctx: ExecutionContext,
|
|
262
|
+
tool_name: str,
|
|
263
|
+
arguments: dict[str, Any],
|
|
264
|
+
) -> ToolCallResult:
|
|
265
|
+
"""
|
|
266
|
+
Evaluate a tool call against the governance policy.
|
|
267
|
+
|
|
268
|
+
Returns a ToolCallResult indicating whether the call is allowed.
|
|
269
|
+
"""
|
|
270
|
+
# Handle human approval callback before the interceptor
|
|
271
|
+
if self.policy.require_human_approval:
|
|
272
|
+
if self._approval_callback:
|
|
273
|
+
approved = self._approval_callback(tool_name, arguments)
|
|
274
|
+
if not approved:
|
|
275
|
+
return ToolCallResult(
|
|
276
|
+
allowed=False,
|
|
277
|
+
reason=f"Human approval denied for tool '{tool_name}'",
|
|
278
|
+
)
|
|
279
|
+
# Approved — skip the interceptor's require_human_approval check
|
|
280
|
+
# by using a policy copy without the flag
|
|
281
|
+
from dataclasses import replace
|
|
282
|
+
policy_for_interceptor = replace(self.policy, require_human_approval=False)
|
|
283
|
+
else:
|
|
284
|
+
return ToolCallResult(
|
|
285
|
+
allowed=False,
|
|
286
|
+
reason=f"Tool '{tool_name}' requires human approval",
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
policy_for_interceptor = self.policy
|
|
290
|
+
|
|
291
|
+
interceptor = PolicyInterceptor(policy_for_interceptor, ctx)
|
|
292
|
+
request = ToolCallRequest(
|
|
293
|
+
tool_name=tool_name,
|
|
294
|
+
arguments=arguments,
|
|
295
|
+
agent_id=ctx.agent_id,
|
|
296
|
+
)
|
|
297
|
+
return interceptor.intercept(request)
|
|
298
|
+
|
|
299
|
+
def get_stats(self) -> dict[str, Any]:
|
|
300
|
+
"""Get governance statistics."""
|
|
301
|
+
total_calls = sum(c.call_count for c in self.contexts.values())
|
|
302
|
+
return {
|
|
303
|
+
"total_sessions": len(self.contexts),
|
|
304
|
+
"wrapped_agents": len(self._wrapped_agents),
|
|
305
|
+
"total_tool_calls": total_calls,
|
|
306
|
+
"audit_entries": len(self._audit_log),
|
|
307
|
+
"policy": {
|
|
308
|
+
"max_tool_calls": self.policy.max_tool_calls,
|
|
309
|
+
"allowed_tools": self.policy.allowed_tools,
|
|
310
|
+
"blocked_patterns": [
|
|
311
|
+
p if isinstance(p, str) else p[0]
|
|
312
|
+
for p in self.policy.blocked_patterns
|
|
313
|
+
],
|
|
314
|
+
"require_human_approval": self.policy.require_human_approval,
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
def health_check(self) -> dict[str, Any]:
|
|
319
|
+
"""Return adapter health status."""
|
|
320
|
+
uptime = time.monotonic() - self._start_time
|
|
321
|
+
status = "degraded" if self._last_error else "healthy"
|
|
322
|
+
return {
|
|
323
|
+
"status": status,
|
|
324
|
+
"backend": "pydantic_ai",
|
|
325
|
+
"backend_available": HAS_PYDANTIC_AI,
|
|
326
|
+
"backend_connected": bool(self._wrapped_agents),
|
|
327
|
+
"last_error": self._last_error,
|
|
328
|
+
"uptime_seconds": round(uptime, 2),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── Helper functions ──────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _get_agent_tools(agent: Any) -> list:
|
|
336
|
+
"""Extract the list of tool entries from a PydanticAI agent."""
|
|
337
|
+
# PydanticAI stores tools in _function_tools (list of Tool objects)
|
|
338
|
+
if hasattr(agent, "_function_tools"):
|
|
339
|
+
return list(agent._function_tools)
|
|
340
|
+
# Fallback for mocks or alternative structures
|
|
341
|
+
if hasattr(agent, "tools"):
|
|
342
|
+
tools = agent.tools
|
|
343
|
+
return list(tools) if tools else []
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _wrap_single_tool(
|
|
348
|
+
tool_entry: Any,
|
|
349
|
+
governed: Any,
|
|
350
|
+
kernel: PydanticAIKernel,
|
|
351
|
+
ctx: ExecutionContext,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Wrap a single tool's function with governance interception."""
|
|
354
|
+
if getattr(tool_entry, "_governed", False):
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
# Determine the tool name and callable
|
|
358
|
+
tool_name = getattr(tool_entry, "name", None) or getattr(
|
|
359
|
+
tool_entry, "__name__", str(tool_entry)
|
|
360
|
+
)
|
|
361
|
+
original_fn = getattr(tool_entry, "function", None) or getattr(
|
|
362
|
+
tool_entry, "_run", None
|
|
363
|
+
)
|
|
364
|
+
if original_fn is None:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
@wraps(original_fn)
|
|
368
|
+
def governed_fn(*args: Any, **kwargs: Any) -> Any:
|
|
369
|
+
"""Governed wrapper that validates and delegates PydanticAI tool calls."""
|
|
370
|
+
# Build arguments dict for policy check
|
|
371
|
+
call_args: dict[str, Any] = kwargs.copy()
|
|
372
|
+
if args:
|
|
373
|
+
call_args["_positional"] = list(args)
|
|
374
|
+
|
|
375
|
+
result = kernel.intercept_tool_call(ctx, tool_name, call_args)
|
|
376
|
+
|
|
377
|
+
if not result.allowed:
|
|
378
|
+
kernel._record_audit(
|
|
379
|
+
"tool_blocked",
|
|
380
|
+
tool_name=tool_name,
|
|
381
|
+
allowed=False,
|
|
382
|
+
reason=result.reason or "",
|
|
383
|
+
arguments=call_args,
|
|
384
|
+
agent_id=ctx.agent_id,
|
|
385
|
+
)
|
|
386
|
+
raise PolicyViolationError(
|
|
387
|
+
result.reason or f"Tool '{tool_name}' blocked by policy"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
ctx.call_count += 1
|
|
391
|
+
kernel._record_audit(
|
|
392
|
+
"tool_executed",
|
|
393
|
+
tool_name=tool_name,
|
|
394
|
+
allowed=True,
|
|
395
|
+
arguments=call_args,
|
|
396
|
+
agent_id=ctx.agent_id,
|
|
397
|
+
)
|
|
398
|
+
return original_fn(*args, **kwargs)
|
|
399
|
+
|
|
400
|
+
# Patch the tool entry
|
|
401
|
+
if hasattr(tool_entry, "function"):
|
|
402
|
+
tool_entry.function = governed_fn
|
|
403
|
+
elif hasattr(tool_entry, "_run"):
|
|
404
|
+
tool_entry._run = governed_fn
|
|
405
|
+
|
|
406
|
+
tool_entry._governed = True
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Convenience function
|
|
410
|
+
def wrap(agent: Any, policy: GovernancePolicy | None = None, **kwargs) -> Any:
|
|
411
|
+
"""Quick wrapper for PydanticAI agents."""
|
|
412
|
+
return PydanticAIKernel(policy, **kwargs).wrap(agent)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
__all__ = [
|
|
416
|
+
"PydanticAIKernel",
|
|
417
|
+
"HumanApprovalRequired",
|
|
418
|
+
"HAS_PYDANTIC_AI",
|
|
419
|
+
"wrap",
|
|
420
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tool-call rate limiting tied to governance policy.
|
|
4
|
+
|
|
5
|
+
This module enforces token-bucket limits for tool invocations, optionally scoped
|
|
6
|
+
per agent and governed by ``GovernancePolicy.max_tool_calls``.
|
|
7
|
+
|
|
8
|
+
See also:
|
|
9
|
+
- hypervisor.security.rate_limiter: runtime-layer per-agent/per-ring limits.
|
|
10
|
+
- agentmesh.services.rate_limiter: service/proxy-level limits in Agent Mesh.
|
|
11
|
+
- agentmesh.services.rate_limit_middleware: HTTP edge middleware in Agent Mesh.
|
|
12
|
+
- agent_os.policies.rate_limiting: shared token-bucket primitives.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .base import GovernancePolicy
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RateLimitStatus:
|
|
25
|
+
"""Snapshot of an agent's rate-limit state."""
|
|
26
|
+
allowed: bool
|
|
27
|
+
remaining_calls: int
|
|
28
|
+
reset_at: float
|
|
29
|
+
wait_seconds: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimiter:
|
|
33
|
+
"""Thread-safe token-bucket rate limiter for tool calls.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
max_calls: Maximum number of calls allowed per time window (bucket size).
|
|
37
|
+
time_window: Duration of the time window in seconds.
|
|
38
|
+
per_agent: If ``True``, limits are tracked independently per agent.
|
|
39
|
+
If ``False``, a single global bucket is used for all agents.
|
|
40
|
+
policy: Optional GovernancePolicy whose ``max_tool_calls`` overrides
|
|
41
|
+
*max_calls*.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_GLOBAL_KEY = "__global__"
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
max_calls: int = 10,
|
|
49
|
+
time_window: float = 60.0,
|
|
50
|
+
per_agent: bool = True,
|
|
51
|
+
policy: Optional[GovernancePolicy] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if max_calls <= 0:
|
|
54
|
+
raise ValueError("max_calls must be positive")
|
|
55
|
+
if time_window <= 0:
|
|
56
|
+
raise ValueError("time_window must be positive")
|
|
57
|
+
|
|
58
|
+
self._max_calls = policy.max_tool_calls if policy is not None else max_calls
|
|
59
|
+
self._time_window = float(time_window)
|
|
60
|
+
self._per_agent = per_agent
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
# Each bucket: (tokens: float, last_refill: float)
|
|
63
|
+
self._buckets: dict[str, list] = {}
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Internal helpers
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _key(self, agent_id: str) -> str:
|
|
70
|
+
return agent_id if self._per_agent else self._GLOBAL_KEY
|
|
71
|
+
|
|
72
|
+
def _refill(self, bucket: list, now: float) -> None:
|
|
73
|
+
"""Add tokens accrued since the last refill."""
|
|
74
|
+
elapsed = now - bucket[1]
|
|
75
|
+
if elapsed > 0:
|
|
76
|
+
rate = self._max_calls / self._time_window
|
|
77
|
+
bucket[0] = min(self._max_calls, bucket[0] + elapsed * rate)
|
|
78
|
+
bucket[1] = now
|
|
79
|
+
|
|
80
|
+
def _get_bucket(self, key: str, now: float) -> list:
|
|
81
|
+
bucket = self._buckets.get(key)
|
|
82
|
+
if bucket is None:
|
|
83
|
+
bucket = [float(self._max_calls), now]
|
|
84
|
+
self._buckets[key] = bucket
|
|
85
|
+
else:
|
|
86
|
+
self._refill(bucket, now)
|
|
87
|
+
return bucket
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Public API
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def allow(self, agent_id: str) -> bool:
|
|
94
|
+
"""Try to consume one token. Returns ``True`` if the call is allowed."""
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
with self._lock:
|
|
97
|
+
bucket = self._get_bucket(self._key(agent_id), now)
|
|
98
|
+
if bucket[0] >= 1.0:
|
|
99
|
+
bucket[0] -= 1.0
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def check(self, agent_id: str) -> RateLimitStatus:
|
|
104
|
+
"""Return current rate-limit status without consuming a token."""
|
|
105
|
+
now = time.monotonic()
|
|
106
|
+
with self._lock:
|
|
107
|
+
bucket = self._get_bucket(self._key(agent_id), now)
|
|
108
|
+
remaining = int(bucket[0])
|
|
109
|
+
allowed = remaining >= 1
|
|
110
|
+
if allowed:
|
|
111
|
+
wait = 0.0
|
|
112
|
+
else:
|
|
113
|
+
rate = self._max_calls / self._time_window
|
|
114
|
+
wait = (1.0 - bucket[0]) / rate if rate > 0 else 0.0
|
|
115
|
+
reset_at = now + self._time_window
|
|
116
|
+
return RateLimitStatus(
|
|
117
|
+
allowed=allowed,
|
|
118
|
+
remaining_calls=remaining,
|
|
119
|
+
reset_at=reset_at,
|
|
120
|
+
wait_seconds=wait,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def wait_time(self, agent_id: str) -> float:
|
|
124
|
+
"""Return seconds until at least one token is available (0.0 if available now)."""
|
|
125
|
+
return self.check(agent_id).wait_seconds
|
|
126
|
+
|
|
127
|
+
def reset(self, agent_id: str) -> None:
|
|
128
|
+
"""Reset the bucket for *agent_id* (or the global bucket if ``per_agent=False``)."""
|
|
129
|
+
with self._lock:
|
|
130
|
+
self._buckets.pop(self._key(agent_id), None)
|