backstage-agents 3.3.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.
Files changed (36) hide show
  1. backstage/agents/__init__.py +38 -0
  2. backstage/agents/_client.py +353 -0
  3. backstage/agents/_config.py +125 -0
  4. backstage/agents/_consumer.py +360 -0
  5. backstage/agents/_context.py +55 -0
  6. backstage/agents/_delivery.py +329 -0
  7. backstage/agents/_endpoints.py +62 -0
  8. backstage/agents/_envelope.py +408 -0
  9. backstage/agents/_lifecycle.py +107 -0
  10. backstage/agents/_refs.py +50 -0
  11. backstage/agents/_sink.py +69 -0
  12. backstage/agents/_transport.py +212 -0
  13. backstage/agents/_types.py +107 -0
  14. backstage/agents/_version.py +3 -0
  15. backstage/agents/integrations/__init__.py +1 -0
  16. backstage/agents/integrations/agno/__init__.py +17 -0
  17. backstage/agents/integrations/agno/__main__.py +66 -0
  18. backstage/agents/integrations/agno/_bridge.py +449 -0
  19. backstage/agents/integrations/agno/_derive.py +407 -0
  20. backstage/agents/integrations/agno/_engine.py +109 -0
  21. backstage/agents/integrations/agno/_heartbeat.py +90 -0
  22. backstage/agents/integrations/agno/_hook.py +397 -0
  23. backstage/agents/integrations/agno/_instrument.py +119 -0
  24. backstage/agents/integrations/agno/_outbox.py +507 -0
  25. backstage/agents/integrations/agno/_ports.py +70 -0
  26. backstage/agents/integrations/agno/_sender.py +107 -0
  27. backstage/agents/integrations/agno/_spool.py +107 -0
  28. backstage/agents/integrations/agno/_sweep.py +86 -0
  29. backstage/agents/integrations/agno/_tools.py +297 -0
  30. backstage/agents/integrations/agno/_worker.py +483 -0
  31. backstage/agents/py.typed +0 -0
  32. backstage/agents/relay/__init__.py +255 -0
  33. backstage/agents/relay/__main__.py +54 -0
  34. backstage_agents-3.3.0.dist-info/METADATA +37 -0
  35. backstage_agents-3.3.0.dist-info/RECORD +36 -0
  36. backstage_agents-3.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,38 @@
1
+ """Khal Backstage — o SDK que o agente usa para escrever na Khal.
2
+
3
+ from backstage.agents import Backstage
4
+
5
+ backstage = Backstage(instance="https://agents-api.suaempresa.khal.app")
6
+
7
+ with backstage.session("conv-abc", contact="cus_987"):
8
+ backstage.identify("cus_987", identity={"name": "Maria Souza"},
9
+ analytics={"cidade": "Recife"})
10
+ backstage.track("proposta.aceita", analytics={"valor": 850})
11
+ backstage.set_status("em-negociacao")
12
+
13
+ As escritas voltam na hora: a rede é de uma thread de fundo, em lote, e nunca levanta.
14
+ """
15
+
16
+ from ._client import Backstage
17
+ from ._types import (
18
+ BackstageConfigurationError,
19
+ BackstageError,
20
+ BackstageInvalidEvent,
21
+ BackstageOrphanEvent,
22
+ Identity,
23
+ OutboxStats,
24
+ Stats,
25
+ )
26
+ from ._version import __version__
27
+
28
+ __all__ = [
29
+ "Identity",
30
+ "Backstage",
31
+ "BackstageConfigurationError",
32
+ "BackstageError",
33
+ "BackstageInvalidEvent",
34
+ "BackstageOrphanEvent",
35
+ "OutboxStats",
36
+ "Stats",
37
+ "__version__",
38
+ ]
@@ -0,0 +1,353 @@
1
+ """A classe `Backstage`, a única superfície pública que escreve eventos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from datetime import datetime
9
+ from typing import Any, Callable, List, Literal, Mapping, Optional, Tuple
10
+
11
+ from . import _envelope, _lifecycle
12
+ from ._config import Settings, env_flag, new_transport, resolve_settings
13
+ from ._consumer import Consumer
14
+ from ._context import SessionScope, current_contact, current_session
15
+ from ._sink import NullSink, Sink
16
+ from ._types import (
17
+ BackstageConfigurationError,
18
+ BackstageError,
19
+ BackstageInvalidEvent,
20
+ BackstageOrphanEvent,
21
+ ErrorHook,
22
+ Identity,
23
+ Stats,
24
+ notify_error,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class Backstage:
31
+ """O cliente. ⚠️ Um por processo, no escopo do módulo: um por requisição é uma fila e uma thread cada."""
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ instance: Optional[str] = None,
37
+ key_id: Optional[str] = None,
38
+ secret: Optional[str] = None,
39
+ enabled: Optional[bool] = None,
40
+ strict: bool = False,
41
+ on_error: Optional[ErrorHook] = None,
42
+ max_queue_size: int = 10_000,
43
+ batch_size: int = 100,
44
+ flush_interval: float = 2.0,
45
+ timeout: float = 10.0,
46
+ ) -> None:
47
+ self._strict = strict
48
+ self._on_error = on_error
49
+ self._orphans = 0
50
+ # Eventos que nem viraram envelope; entram em `stats().failed`.
51
+ self._invalid = 0
52
+ self._counters_lock = threading.Lock()
53
+ self._closed = False
54
+ # UM destino. A fila HTTP nasce na primeira escrita: com o outbox ligado antes dela, nunca nasce.
55
+ self._sink: Sink = NullSink()
56
+ self._sink_lock = threading.Lock()
57
+ # O que o finalizador enxerga: o sink atual, nunca `self`.
58
+ self._sink_box: List[Sink] = []
59
+
60
+ resolution = resolve_settings(
61
+ instance=instance,
62
+ key_id=key_id,
63
+ secret=secret,
64
+ enabled=enabled,
65
+ strict=strict,
66
+ on_error=on_error,
67
+ max_queue_size=max_queue_size,
68
+ batch_size=batch_size,
69
+ flush_interval=flush_interval,
70
+ timeout=timeout,
71
+ )
72
+ for warning in resolution.warnings:
73
+ logger.warning(warning)
74
+ if resolution.problem is not None:
75
+ if strict:
76
+ raise BackstageConfigurationError(resolution.problem)
77
+ logger.log(resolution.level, resolution.problem)
78
+ self._settings: Optional[Settings] = resolution.settings
79
+ if self._settings is None:
80
+ # Desligado é desligado: nenhuma thread, nenhuma fila, nenhum gancho.
81
+ return
82
+
83
+ logger.info("backstage: enviando para %s", self._settings.base_url)
84
+ # Sem isto a thread de um `Backstage()` coletado sem `close()` gira para sempre.
85
+ self._finalizer = _lifecycle.finalize_sink(self, self._sink_box)
86
+ _lifecycle.register(self)
87
+
88
+ # -- escrita -------------------------------------------------------------
89
+
90
+ def track(
91
+ self,
92
+ name: str,
93
+ properties: Optional[Mapping[str, Any]] = None,
94
+ *,
95
+ analytics: Optional[Mapping[str, Any]] = None,
96
+ display: Optional[Mapping[str, Any]] = None,
97
+ session: Optional[str] = None,
98
+ user: Optional[str] = None,
99
+ occurred_at: Optional[datetime] = None,
100
+ ) -> None:
101
+ """Registra que algo aconteceu. Volta na hora; nunca levanta.
102
+
103
+ `analytics`: candidato a dimensão de análise; `display`: texto para ler, nunca analisado;
104
+ `properties`: lido como `analytics` (que vence na chave repetida). Sem sessão nem `user=`,
105
+ o evento é órfão, e isso é válido.
106
+ """
107
+
108
+ if not self._writable():
109
+ return
110
+ session_ref, contact_ref = _scope_refs(session, user)
111
+ if session_ref is None and contact_ref is None:
112
+ if self._strict:
113
+ raise BackstageOrphanEvent(
114
+ f"backstage: track({name!r}) sem session e sem user. "
115
+ "Abra um `with backstage.session(...)` ou passe session=/user=."
116
+ )
117
+ # Sem log: um cron noturno é órfão por natureza.
118
+ with self._counters_lock:
119
+ self._orphans += 1
120
+ self._dispatch(
121
+ f"track({name!r})",
122
+ lambda: _envelope.track(
123
+ name=name,
124
+ session_ref=session_ref,
125
+ contact_ref=contact_ref,
126
+ occurred_at=occurred_at,
127
+ properties=properties,
128
+ analytics=analytics,
129
+ display=display,
130
+ ),
131
+ )
132
+
133
+ def identify(
134
+ self,
135
+ user: str,
136
+ traits: Optional[Mapping[str, Any]] = None,
137
+ *,
138
+ identity: Optional[Identity] = None,
139
+ display: Optional[Mapping[str, Any]] = None,
140
+ analytics: Optional[Mapping[str, Any]] = None,
141
+ session: Optional[str] = None,
142
+ occurred_at: Optional[datetime] = None,
143
+ ) -> None:
144
+ """Diz quem é a pessoa por trás do `contact_ref` (`user`). Volta na hora.
145
+
146
+ `identity`: a lista fechada que resolve a pessoa, nunca analisada; `display`: para ler;
147
+ `analytics`: candidato a dimensão; `traits`: o saco sem declaração.
148
+ """
149
+
150
+ if not self._writable():
151
+ return
152
+ session_ref, _ = _scope_refs(session, None)
153
+ # ⚠️ O rótulo não leva o `user`: é telefone ou e-mail, e iria para o log (LGPD).
154
+ self._dispatch(
155
+ "identify()",
156
+ lambda: _envelope.identify(
157
+ contact_ref=user,
158
+ session_ref=session_ref,
159
+ occurred_at=occurred_at,
160
+ traits=traits,
161
+ identity=identity,
162
+ analytics=analytics,
163
+ display=display,
164
+ ),
165
+ )
166
+
167
+ def set_status(
168
+ self,
169
+ status: str,
170
+ *,
171
+ session: Optional[str] = None,
172
+ note: Optional[str] = None,
173
+ occurred_at: Optional[datetime] = None,
174
+ ) -> None:
175
+ """Move a conversa para um status do catálogo (o slug ou o uuid). Volta na hora.
176
+
177
+ ⚠️ Exige a conversa (`session()` ou `session=`): sem ela, avisa sempre e conta em `failed`.
178
+ """
179
+
180
+ if not self._writable():
181
+ return
182
+ session_ref, _ = _scope_refs(session, None)
183
+ self._dispatch(
184
+ f"set_status({status!r})",
185
+ lambda: _envelope.status(
186
+ status_ref=status, session_ref=session_ref, note=note, occurred_at=occurred_at
187
+ ),
188
+ )
189
+
190
+ def session(self, session_id: str, *, contact: Optional[str] = None) -> SessionScope:
191
+ """Abre o escopo de uma conversa. Aninhar funciona; o argumento explícito vence o escopo."""
192
+
193
+ return SessionScope(session_id, contact)
194
+
195
+ # -- controle ------------------------------------------------------------
196
+
197
+ def flush(self, timeout: float = 5.0) -> bool:
198
+ """Espera o que já foi aceito ter veredito; com o outbox, até a memória entrar no banco e
199
+ `pending == 0`. `False` se estourou o prazo. Não é para cada turno: desfaz o lote."""
200
+
201
+ return self._sink.flush(timeout)
202
+
203
+ def close(self, timeout: float = 5.0) -> None:
204
+ """Drena com teto e para a thread. Idempotente; depois disto, no-op.
205
+
206
+ O prazo é UM para tudo: cada etapa recebe o que sobrou, não o `timeout` inteiro.
207
+ """
208
+
209
+ deadline = time.monotonic() + max(0.0, timeout)
210
+ # Sob o lock do `_ensure_sink`: um write que já passou do `_writable()` não cria fila depois daqui.
211
+ with self._sink_lock:
212
+ self._closed = True
213
+ sink = self._sink
214
+ sink.close(max(0.0, deadline - time.monotonic()))
215
+
216
+ def stats(self) -> Stats:
217
+ current = self._sink
218
+ sink = current.stats()
219
+ with self._counters_lock:
220
+ orphans, invalid = self._orphans, self._invalid
221
+ # O que não virou envelope entra em `failed`; `dropped` é só fila cheia.
222
+ return Stats(
223
+ queued=sink.queued,
224
+ sent=sink.sent,
225
+ dropped=sink.dropped,
226
+ failed=sink.failed + invalid,
227
+ orphans=orphans,
228
+ outbox=current.outbox_stats(),
229
+ )
230
+
231
+ @property
232
+ def enabled(self) -> bool:
233
+ return self._settings is not None
234
+
235
+ def __enter__(self) -> Backstage:
236
+ return self
237
+
238
+ def __exit__(self, *exc_info: object) -> Literal[False]:
239
+ self.close()
240
+ return False
241
+
242
+ # -- para as integrações -------------------------------------------------
243
+
244
+ @property
245
+ def _error_hook(self) -> Optional[ErrorHook]:
246
+ """O `on_error` do cliente, para quem precisa avisar pelo mesmo gancho (o tracker)."""
247
+
248
+ return self._on_error
249
+
250
+ def _attach_sink(self, sink: Sink, retire_timeout: float = 2.0) -> bool:
251
+ """Troca o destino (o outbox do Agno). A fila HTTP que já existia drena para a porta e fecha.
252
+
253
+ `False` com o cliente fechado: nada é trocado.
254
+ """
255
+
256
+ with self._sink_lock:
257
+ if self._closed:
258
+ return False
259
+ old = self._sink
260
+ self._set_sink(sink)
261
+ old.close(retire_timeout)
262
+ return True
263
+
264
+ # -- interno -------------------------------------------------------------
265
+
266
+ def _writable(self) -> bool:
267
+ # Lido a cada evento: o kill switch vale sem reiniciar o processo.
268
+ if self._settings is None or env_flag("BACKSTAGE_DISABLED"):
269
+ return False
270
+ if self._closed:
271
+ logger.warning("backstage: cliente ja fechado, evento ignorado")
272
+ return False
273
+ return True
274
+
275
+ def _dispatch(self, label: str, build: Callable[[], dict[str, Any]]) -> None:
276
+ """Monta, serializa e enfileira. Fora de `strict`, não levanta por nada.
277
+
278
+ `label` nomeia o evento no warning e nunca leva dado pessoal.
279
+ """
280
+
281
+ try:
282
+ # Montar e serializar no MESMO try: `dict(properties)` com o argumento errado
283
+ # também falha aqui, e não na cara de quem chamou.
284
+ envelope = build()
285
+ payload = _envelope.serialize(envelope, label)
286
+ except Exception as exc: # noqa: BLE001 — nunca `BaseException`: o Ctrl-C passa
287
+ self._reject(label, exc)
288
+ return
289
+ sink = self._ensure_sink()
290
+ # Um sink fechado no meio da troca (`_attach_sink`) recusa; o novo recebe.
291
+ if not sink.submit(payload, envelope) and self._sink is not sink:
292
+ self._sink.submit(payload, envelope)
293
+
294
+ def _reject(self, label: str, exc: Exception) -> None:
295
+ """O único lugar que descarta um evento antes da fila."""
296
+
297
+ if self._strict:
298
+ if isinstance(exc, BackstageError):
299
+ raise exc
300
+ raise BackstageInvalidEvent(f"backstage: {label} nao virou envelope") from exc
301
+ logger.warning("backstage: %s nao virou envelope e foi ignorado: %s", label, exc)
302
+ with self._counters_lock:
303
+ self._invalid += 1
304
+ notify_error(self._on_error, exc)
305
+
306
+ def _ensure_sink(self) -> Sink:
307
+ sink = self._sink
308
+ if not isinstance(sink, NullSink) or self._settings is None:
309
+ return sink
310
+ with self._sink_lock:
311
+ if isinstance(self._sink, NullSink) and not self._closed:
312
+ settings = self._settings
313
+ self._set_sink(
314
+ Consumer(
315
+ new_transport(settings),
316
+ max_queue_size=settings.max_queue_size,
317
+ batch_size=settings.batch_size,
318
+ flush_interval=settings.flush_interval,
319
+ on_error=settings.on_error,
320
+ )
321
+ )
322
+ return self._sink
323
+
324
+ def _set_sink(self, sink: Sink) -> None:
325
+ self._sink = sink
326
+ # A caixa aponta para o sink ATUAL, senão o finalizador pararia o velho depois do fork.
327
+ self._sink_box[:] = [sink]
328
+
329
+ # -- _lifecycle.Component ------------------------------------------------
330
+
331
+ def _wake(self) -> None:
332
+ self._sink.wake()
333
+
334
+ def _drain(self, deadline: float) -> int:
335
+ if self._sink.flush(max(0.0, deadline - time.monotonic())):
336
+ return 0
337
+ return self.stats().queued
338
+
339
+ def _after_fork(self) -> None:
340
+ # O fork não copia threads, e a fila herdada pode ter um lock travado.
341
+ self._sink_lock = threading.Lock()
342
+ self._set_sink(self._sink.after_fork())
343
+
344
+
345
+ def _scope_refs(session: Optional[str], user: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
346
+ """O argumento explícito vence o escopo; `""` é descuido, não referência."""
347
+
348
+ session_ref = session if session is not None else current_session()
349
+ contact_ref = user if user is not None else current_contact()
350
+ return (session_ref if session_ref != "" else None, contact_ref if contact_ref != "" else None)
351
+
352
+
353
+ __all__ = ["Backstage"]
@@ -0,0 +1,125 @@
1
+ """A configuração do cliente, resolvida UMA vez: argumentos, ambiente e o que desliga."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Mapping, Optional, Tuple
9
+
10
+ from ._endpoints import normalize_instance
11
+ from ._transport import Transport
12
+ from ._types import ErrorHook
13
+
14
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Settings:
19
+ """O cliente ligado. Só existe com instância e credenciais válidas."""
20
+
21
+ base_url: str
22
+ key_id: str
23
+ secret: str
24
+ timeout: float
25
+ strict: bool
26
+ on_error: Optional[ErrorHook]
27
+ max_queue_size: int
28
+ batch_size: int
29
+ flush_interval: float
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Resolution:
34
+ """`settings` é `None` quando o cliente fica em no-op; `problem` diz por quê (com o nível do log)."""
35
+
36
+ settings: Optional[Settings]
37
+ problem: Optional[str] = None
38
+ level: int = logging.WARNING
39
+ warnings: Tuple[str, ...] = ()
40
+
41
+
42
+ def env_flag(name: str, environ: Optional[Mapping[str, str]] = None) -> bool:
43
+ value = (os.environ if environ is None else environ).get(name)
44
+ return value is not None and value.strip().lower() in _TRUTHY
45
+
46
+
47
+ def resolve_settings(
48
+ *,
49
+ instance: Optional[str],
50
+ key_id: Optional[str],
51
+ secret: Optional[str],
52
+ enabled: Optional[bool],
53
+ strict: bool,
54
+ on_error: Optional[ErrorHook],
55
+ max_queue_size: int,
56
+ batch_size: int,
57
+ flush_interval: float,
58
+ timeout: float,
59
+ environ: Optional[Mapping[str, str]] = None,
60
+ ) -> Resolution:
61
+ """Pura: não loga nem levanta. Quem chama decide entre `logger` e `strict`."""
62
+
63
+ env = os.environ if environ is None else environ
64
+ if enabled is None:
65
+ enabled = not env_flag("BACKSTAGE_DISABLED", env)
66
+ if not enabled:
67
+ return Resolution(None)
68
+
69
+ warnings = []
70
+ credentials = []
71
+ for name, value in (("BACKSTAGE_KEY_ID", key_id), ("BACKSTAGE_SECRET", secret)):
72
+ value = value if value is not None else env.get(name)
73
+ # Um `\n` do `echo` ou do arquivo de segredo vira 401 eterno; o valor nunca vai ao log.
74
+ if value is not None and value != value.strip():
75
+ value = value.strip()
76
+ warnings.append(f"backstage: {name} tinha espaco ou quebra de linha nas pontas — removidos")
77
+ credentials.append(value or None)
78
+
79
+ if instance is None:
80
+ instance = env.get("BACKSTAGE_INSTANCE") or None
81
+ if instance is None:
82
+ return Resolution(
83
+ None,
84
+ "backstage: BACKSTAGE_INSTANCE ausente — diga a raiz da porta da sua instancia "
85
+ "(Backstage(instance=\"https://agents-api.suaempresa.khal.app\")). O cliente fica em no-op.",
86
+ logging.ERROR,
87
+ tuple(warnings),
88
+ )
89
+ url, reason = normalize_instance(instance)
90
+ if url is None:
91
+ return Resolution(None, f"backstage: {reason}. O cliente fica em no-op.", logging.ERROR, tuple(warnings))
92
+
93
+ key, sec = credentials
94
+ if not (key and sec):
95
+ return Resolution(
96
+ None,
97
+ "backstage: BACKSTAGE_KEY_ID e/ou BACKSTAGE_SECRET ausentes — o cliente fica em "
98
+ "no-op. Nenhum evento sera enviado.",
99
+ logging.WARNING,
100
+ tuple(warnings),
101
+ )
102
+ settings = Settings(
103
+ base_url=url,
104
+ key_id=key,
105
+ secret=sec,
106
+ timeout=timeout,
107
+ strict=strict,
108
+ on_error=on_error,
109
+ max_queue_size=max_queue_size,
110
+ batch_size=batch_size,
111
+ flush_interval=flush_interval,
112
+ )
113
+ return Resolution(settings, warnings=tuple(warnings))
114
+
115
+
116
+ def new_transport(settings: Settings, timeout: Optional[float] = None) -> Transport:
117
+ return Transport(
118
+ base_url=settings.base_url,
119
+ key_id=settings.key_id,
120
+ secret=settings.secret,
121
+ timeout=settings.timeout if timeout is None else timeout,
122
+ )
123
+
124
+
125
+ __all__ = ["Resolution", "Settings", "env_flag", "new_transport", "resolve_settings"]