crewlayer 0.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.
@@ -0,0 +1,527 @@
1
+ """LlamaIndex integration for the CrewLayer SDK.
2
+
3
+ Provides four adapters:
4
+
5
+ - ``CrewLayerMemoryBuffer`` — BaseMemory backed by CrewLayer short-term memory
6
+ - ``CrewLayerVectorIndex`` — index backed by CrewLayer semantic recall (pgvector)
7
+ - ``CrewLayerQueryEngine`` — query engine returned by ``index.as_query_engine()``,
8
+ logs every query as a ``llamaindex.query`` action
9
+ - ``CrewLayerCallbackManager`` — BaseCallbackHandler that logs LLM calls and
10
+ tool/function calls as CrewLayer actions
11
+
12
+ Install::
13
+
14
+ pip install crewlayer[llamaindex]
15
+
16
+ Usage::
17
+
18
+ from crewlayer import CrewLayerClient
19
+ from crewlayer.integrations.llamaindex import (
20
+ CrewLayerMemoryBuffer,
21
+ CrewLayerVectorIndex,
22
+ CrewLayerCallbackManager,
23
+ )
24
+
25
+ client = CrewLayerClient(api_key="crwl_...")
26
+
27
+ # Chat memory
28
+ memory = CrewLayerMemoryBuffer(client=client, agent_id="agent-uuid")
29
+
30
+ # Vector index + query engine
31
+ index = CrewLayerVectorIndex(client=client, agent_id="agent-uuid")
32
+ index.insert(document)
33
+ engine = index.as_query_engine()
34
+ response = engine.query("¿qué recuerdas sobre el cliente?")
35
+ print(response.response)
36
+
37
+ # Callback handler (add to LlamaIndex's CallbackManager)
38
+ from llama_index.core.callbacks import CallbackManager
39
+ handler = CrewLayerCallbackManager(client=client, agent_id="agent-uuid")
40
+ llm = OpenAI(callback_manager=CallbackManager([handler]))
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import time
45
+ from dataclasses import dataclass, field
46
+ from typing import Any
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Optional LlamaIndex imports — graceful fallback when not installed
50
+ # ---------------------------------------------------------------------------
51
+
52
+ try:
53
+ from llama_index.core.memory import BaseMemory as _LIBaseMemory # type: ignore[import]
54
+ from llama_index.core.llms import ChatMessage, MessageRole # type: ignore[import]
55
+ _LI_MEMORY = True
56
+ except ImportError:
57
+ _LI_MEMORY = False
58
+ _LIBaseMemory = object # type: ignore[assignment, misc]
59
+
60
+ class MessageRole: # type: ignore[no-redef]
61
+ USER = "user"
62
+ ASSISTANT = "assistant"
63
+ SYSTEM = "system"
64
+
65
+ class ChatMessage: # type: ignore[no-redef]
66
+ """Stub — requires llama-index-core."""
67
+ def __init__(self, role: Any = "user", content: str = "", **kwargs: Any) -> None:
68
+ self.role = role
69
+ self.content = content
70
+
71
+
72
+ try:
73
+ from llama_index.core.callbacks.base_handler import ( # type: ignore[import]
74
+ BaseCallbackHandler as _LIBaseCallbackHandler,
75
+ )
76
+ from llama_index.core.callbacks.schema import CBEventType # type: ignore[import]
77
+ _LI_CALLBACKS = True
78
+ except ImportError:
79
+ _LI_CALLBACKS = False
80
+ _LIBaseCallbackHandler = object # type: ignore[assignment, misc]
81
+
82
+ class CBEventType: # type: ignore[no-redef]
83
+ """Stub — requires llama-index-core."""
84
+ LLM = "llm"
85
+ FUNCTION_CALL = "function_call"
86
+ AGENT_STEP = "agent_step"
87
+ QUERY = "query"
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Internal helpers
92
+ # ---------------------------------------------------------------------------
93
+
94
+ # Event names that are worth logging to CrewLayer (avoids noise from EMBEDDING etc.)
95
+ _LOGGED_EVENTS: frozenset[str] = frozenset({"llm", "function_call", "agent_step"})
96
+
97
+
98
+ def _role_str(role: Any) -> str:
99
+ """Normalise a LlamaIndex MessageRole (enum or str) to a plain string."""
100
+ s = str(role.value if hasattr(role, "value") else role)
101
+ return "user" if s == "human" else s
102
+
103
+
104
+ def _event_name(event_type: Any) -> str:
105
+ return str(event_type.value if hasattr(event_type, "value") else event_type)
106
+
107
+
108
+ @dataclass
109
+ class QueryResponse:
110
+ """Lightweight response returned by ``CrewLayerQueryEngine.query()``.
111
+
112
+ Compatible with direct use; also holds the raw ``source_nodes`` from
113
+ CrewLayer so callers can inspect similarity scores without needing
114
+ the full LlamaIndex response hierarchy.
115
+ """
116
+
117
+ response: str
118
+ source_nodes: list[Any] = field(default_factory=list)
119
+
120
+ def __str__(self) -> str:
121
+ return self.response
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # CrewLayerMemoryBuffer
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ class CrewLayerMemoryBuffer(_LIBaseMemory): # type: ignore[misc]
130
+ """LlamaIndex ``BaseMemory`` backed by CrewLayer short-term memory.
131
+
132
+ Each ``put()`` call appends a message to the agent's Redis session store.
133
+ ``get()`` fetches recent messages and converts them to ``ChatMessage`` objects.
134
+
135
+ Args:
136
+ client: A ``CrewLayerClient`` (sync) instance.
137
+ agent_id: Target agent UUID.
138
+ session_id: Session key (default ``"default"``).
139
+ limit: Max messages returned by ``get()`` (default ``50``).
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ *,
145
+ client: Any,
146
+ agent_id: str,
147
+ session_id: str = "default",
148
+ limit: int = 50,
149
+ ) -> None:
150
+ # Use __dict__ directly to bypass Pydantic's __setattr__ when
151
+ # llama_index's BaseMemory is a Pydantic model.
152
+ try:
153
+ super().__init__()
154
+ except TypeError:
155
+ pass
156
+ self.__dict__.update({
157
+ "_client": client,
158
+ "_agent_id": agent_id,
159
+ "_session_id": session_id,
160
+ "_limit": limit,
161
+ })
162
+
163
+ # ------------------------------------------------------------------
164
+ # BaseMemory interface
165
+ # ------------------------------------------------------------------
166
+
167
+ def get(self, input: str | None = None, **kwargs: Any) -> list[Any]:
168
+ """Return recent messages as ``ChatMessage`` objects."""
169
+ sm = self._client.memory.messages(
170
+ self._agent_id,
171
+ session_id=self._session_id,
172
+ limit=self._limit,
173
+ )
174
+ return [
175
+ ChatMessage(role=_role_str(m.role), content=m.content)
176
+ for m in sm.messages
177
+ ]
178
+
179
+ def get_all(self) -> list[Any]:
180
+ """Return all stored messages — delegates to ``get()``."""
181
+ return self.get()
182
+
183
+ def put(self, message: Any) -> None:
184
+ """Append a single ``ChatMessage`` to CrewLayer short-term memory."""
185
+ role = _role_str(getattr(message, "role", "user"))
186
+ content = str(getattr(message, "content", ""))
187
+ self._client.memory.append(
188
+ self._agent_id,
189
+ role,
190
+ content,
191
+ session_id=self._session_id,
192
+ )
193
+
194
+ def set(self, messages: list[Any]) -> None:
195
+ """Overwrite history by appending each message in order."""
196
+ for m in messages:
197
+ self.put(m)
198
+
199
+ def reset(self) -> None:
200
+ """No-op — use a fresh ``session_id`` or rely on Redis TTL."""
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # CrewLayerVectorIndex
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ class CrewLayerVectorIndex:
209
+ """Vector index backed by CrewLayer semantic recall.
210
+
211
+ ``insert()`` persists documents as long-term memories via the extract
212
+ endpoint. ``similarity_search()`` uses pgvector cosine similarity.
213
+ ``as_query_engine()`` returns a :class:`CrewLayerQueryEngine` that logs
214
+ every query as a ``llamaindex.query`` action.
215
+
216
+ Args:
217
+ client: A ``CrewLayerClient`` (sync) instance.
218
+ agent_id: Target agent UUID.
219
+ similarity_top_k: Default number of results (default ``4``).
220
+ min_similarity: Minimum cosine similarity threshold (default ``0.0``).
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ *,
226
+ client: Any,
227
+ agent_id: str,
228
+ similarity_top_k: int = 4,
229
+ min_similarity: float = 0.0,
230
+ ) -> None:
231
+ self._client = client
232
+ self._agent_id = agent_id
233
+ self._similarity_top_k = similarity_top_k
234
+ self._min_similarity = min_similarity
235
+
236
+ def insert(self, document: Any) -> list[str]:
237
+ """Persist a document as long-term memory via the extract endpoint.
238
+
239
+ Accepts any object with a ``.text`` or ``.get_content()`` attribute,
240
+ or anything ``str()``-able.
241
+
242
+ Returns the list of new memory IDs.
243
+ """
244
+ text = (
245
+ getattr(document, "text", None)
246
+ or (
247
+ document.get_content()
248
+ if callable(getattr(document, "get_content", None))
249
+ else None
250
+ )
251
+ or str(document)
252
+ )
253
+ result = self._client.memory.extract(
254
+ self._agent_id,
255
+ conversation=f"Remember the following:\n{text}",
256
+ )
257
+ return result.memory_ids
258
+
259
+ def similarity_search(self, query: str, top_k: int | None = None) -> list[Any]:
260
+ """Return the top-k memories most similar to *query*.
261
+
262
+ Returns a list of ``MemoryItem`` objects (from ``_types.py``).
263
+ """
264
+ limit = top_k if top_k is not None else self._similarity_top_k
265
+ result = self._client.memory.recall(
266
+ self._agent_id,
267
+ query,
268
+ limit=limit,
269
+ min_similarity=self._min_similarity,
270
+ )
271
+ return result.results
272
+
273
+ def as_query_engine(
274
+ self,
275
+ *,
276
+ session_id: str | None = None,
277
+ similarity_top_k: int | None = None,
278
+ ) -> "CrewLayerQueryEngine":
279
+ """Return a :class:`CrewLayerQueryEngine` backed by this index."""
280
+ return CrewLayerQueryEngine(
281
+ index=self,
282
+ session_id=session_id,
283
+ similarity_top_k=similarity_top_k,
284
+ )
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # CrewLayerQueryEngine
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ class CrewLayerQueryEngine:
293
+ """Query engine that retrieves from CrewLayer and logs each call as an action.
294
+
295
+ Returned by :meth:`CrewLayerVectorIndex.as_query_engine`. Can also be
296
+ instantiated directly if you already have an index reference.
297
+
298
+ Args:
299
+ index: A :class:`CrewLayerVectorIndex` instance.
300
+ session_id: Optional session to associate actions with.
301
+ similarity_top_k: Override the index default ``similarity_top_k``.
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ *,
307
+ index: CrewLayerVectorIndex,
308
+ session_id: str | None = None,
309
+ similarity_top_k: int | None = None,
310
+ ) -> None:
311
+ self._index = index
312
+ self._session_id = session_id
313
+ self._similarity_top_k = similarity_top_k
314
+
315
+ def query(self, query_str: str, **kwargs: Any) -> QueryResponse:
316
+ """Retrieve memories for *query_str*, log the call, and return a response.
317
+
318
+ The action is always logged with ``tool_name="llamaindex.query"``.
319
+ The response text is the concatenation of the top result contents.
320
+ ``source_nodes`` holds the raw ``MemoryItem`` list for further inspection.
321
+ """
322
+ start = time.monotonic()
323
+ try:
324
+ nodes = self._index.similarity_search(
325
+ query_str,
326
+ top_k=self._similarity_top_k,
327
+ )
328
+ duration_ms = max(0, int((time.monotonic() - start) * 1000))
329
+ response_text = "\n\n".join(item.content for item in nodes) if nodes else ""
330
+ self._index._client.actions.log(
331
+ self._index._agent_id,
332
+ tool_name="llamaindex.query",
333
+ input_params={"query": query_str},
334
+ output_result={
335
+ "results_count": len(nodes),
336
+ "response": response_text[:500],
337
+ },
338
+ status="success",
339
+ session_id=self._session_id,
340
+ duration_ms=duration_ms,
341
+ )
342
+ return QueryResponse(response=response_text, source_nodes=nodes)
343
+ except Exception as exc:
344
+ duration_ms = max(0, int((time.monotonic() - start) * 1000))
345
+ self._index._client.actions.log(
346
+ self._index._agent_id,
347
+ tool_name="llamaindex.query",
348
+ input_params={"query": query_str},
349
+ output_result={},
350
+ status="error",
351
+ error_msg=str(exc),
352
+ session_id=self._session_id,
353
+ duration_ms=duration_ms,
354
+ )
355
+ raise
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # CrewLayerCallbackManager
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ class CrewLayerCallbackManager(_LIBaseCallbackHandler): # type: ignore[misc]
364
+ """LlamaIndex ``BaseCallbackHandler`` that logs LLM and tool calls to CrewLayer.
365
+
366
+ Add an instance of this class to LlamaIndex's ``CallbackManager`` to
367
+ automatically record every LLM call, function call, and agent step as
368
+ an immutable action entry in CrewLayer.
369
+
370
+ Tracked event types: ``llm``, ``function_call``, ``agent_step``.
371
+
372
+ Args:
373
+ client: A ``CrewLayerClient`` (sync) instance.
374
+ agent_id: Target agent UUID.
375
+ session_id: Optional session to associate actions with.
376
+
377
+ Usage::
378
+
379
+ from llama_index.core.callbacks import CallbackManager
380
+ handler = CrewLayerCallbackManager(client=client, agent_id="agent-uuid")
381
+ callback_manager = CallbackManager([handler])
382
+ llm = OpenAI(callback_manager=callback_manager)
383
+ """
384
+
385
+ def __init__(
386
+ self,
387
+ *,
388
+ client: Any,
389
+ agent_id: str,
390
+ session_id: str | None = None,
391
+ ) -> None:
392
+ # BaseCallbackHandler (when installed) expects event ignore lists.
393
+ try:
394
+ super().__init__(event_starts_to_ignore=[], event_ends_to_ignore=[])
395
+ except TypeError:
396
+ try:
397
+ super().__init__()
398
+ except TypeError:
399
+ pass
400
+ self.__dict__.update({
401
+ "_client": client,
402
+ "_agent_id": agent_id,
403
+ "_session_id": session_id,
404
+ "_start_times": {}, # event_id → monotonic start
405
+ "_start_payloads": {}, # event_id → payload dict at start
406
+ })
407
+
408
+ # ------------------------------------------------------------------
409
+ # BaseCallbackHandler interface
410
+ # ------------------------------------------------------------------
411
+
412
+ def on_event_start(
413
+ self,
414
+ event_type: Any,
415
+ payload: dict[str, Any] | None = None,
416
+ event_id: str = "",
417
+ parent_id: str = "",
418
+ **kwargs: Any,
419
+ ) -> str:
420
+ """Record the start time for tracked events."""
421
+ name = _event_name(event_type)
422
+ if name in _LOGGED_EVENTS and event_id:
423
+ self._start_times[event_id] = time.monotonic()
424
+ self._start_payloads[event_id] = payload or {}
425
+ return event_id
426
+
427
+ def on_event_end(
428
+ self,
429
+ event_type: Any,
430
+ payload: dict[str, Any] | None = None,
431
+ event_id: str = "",
432
+ **kwargs: Any,
433
+ ) -> None:
434
+ """Log the completed event as a CrewLayer action."""
435
+ name = _event_name(event_type)
436
+ if name not in _LOGGED_EVENTS:
437
+ return
438
+
439
+ payload = payload or {}
440
+ duration_ms = self._pop_duration(event_id)
441
+ tool_name = f"llamaindex.{name}"
442
+
443
+ # Extract a concise output summary from the payload
444
+ output = _extract_output(payload)
445
+ input_info = _extract_input(self._start_payloads.pop(event_id, {}))
446
+
447
+ try:
448
+ self._client.actions.log(
449
+ self._agent_id,
450
+ tool_name=tool_name,
451
+ input_params=input_info,
452
+ output_result=output,
453
+ status="success",
454
+ session_id=self._session_id,
455
+ duration_ms=duration_ms,
456
+ )
457
+ except Exception:
458
+ pass # Never block the LlamaIndex pipeline
459
+
460
+ def start_trace(self, trace_id: str | None = None) -> None:
461
+ """No-op — CrewLayer tracing is handled at the action level."""
462
+
463
+ def end_trace(
464
+ self,
465
+ trace_id: str | None = None,
466
+ trace_map: dict[str, Any] | None = None,
467
+ ) -> None:
468
+ """No-op."""
469
+
470
+ # ------------------------------------------------------------------
471
+ # Internal helpers
472
+ # ------------------------------------------------------------------
473
+
474
+ def _pop_duration(self, event_id: str) -> int | None:
475
+ start = self._start_times.pop(event_id, None)
476
+ if start is None:
477
+ return None
478
+ return max(0, int((time.monotonic() - start) * 1000))
479
+
480
+
481
+ def _extract_output(payload: dict[str, Any]) -> dict[str, Any]:
482
+ """Pull a loggable summary from a LlamaIndex event end payload."""
483
+ out: dict[str, Any] = {}
484
+
485
+ # LLM response
486
+ response = payload.get("response")
487
+ if response is not None:
488
+ # CompletionResponse / ChatResponse both have .text or .message.content
489
+ text = getattr(response, "text", None)
490
+ if text is None:
491
+ msg = getattr(response, "message", None)
492
+ text = getattr(msg, "content", None) if msg is not None else None
493
+ if text is not None:
494
+ out["response"] = str(text)[:500]
495
+
496
+ # Function call response
497
+ fc_response = payload.get("function_call_response")
498
+ if fc_response is not None:
499
+ out["function_call_response"] = str(fc_response)[:500]
500
+
501
+ # Token usage
502
+ for key in ("num_input_tokens", "num_output_tokens", "model_name"):
503
+ if key in payload:
504
+ out[key] = payload[key]
505
+
506
+ return out or {"raw": str(payload)[:200]}
507
+
508
+
509
+ def _extract_input(payload: dict[str, Any]) -> dict[str, Any]:
510
+ """Pull a loggable summary from a LlamaIndex event start payload."""
511
+ inp: dict[str, Any] = {}
512
+
513
+ # LLM messages list
514
+ messages = payload.get("messages")
515
+ if messages:
516
+ inp["message_count"] = len(messages)
517
+ last = messages[-1]
518
+ inp["last_message"] = str(
519
+ getattr(last, "content", str(last))
520
+ )[:300]
521
+
522
+ # Function call info
523
+ for key in ("function_call", "function_name", "model_name"):
524
+ if key in payload:
525
+ inp[key] = str(payload[key])[:200]
526
+
527
+ return inp or {}
crewlayer/py.typed ADDED
File without changes