qveris 0.2.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.
qveris/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ from .agent.core import Agent
2
+ from .client.api import QverisClient
3
+ from .config import QverisConfig, AgentConfig
4
+ from .types import (
5
+ CompactBillingStatement,
6
+ CreditsLedgerItem,
7
+ CreditsLedgerResponse,
8
+ Message,
9
+ SearchResponse,
10
+ StreamEvent,
11
+ ToolExecutionResponse,
12
+ ToolInfo,
13
+ ToolParameter,
14
+ UsageEventItem,
15
+ UsageHistoryResponse,
16
+ )
17
+
18
+ __all__ = [
19
+ "Agent",
20
+ "QverisClient",
21
+ "QverisConfig",
22
+ "AgentConfig",
23
+ "Message",
24
+ "StreamEvent",
25
+ "ToolInfo",
26
+ "ToolParameter",
27
+ "SearchResponse",
28
+ "ToolExecutionResponse",
29
+ "CompactBillingStatement",
30
+ "UsageEventItem",
31
+ "UsageHistoryResponse",
32
+ "CreditsLedgerItem",
33
+ "CreditsLedgerResponse",
34
+ ]
@@ -0,0 +1,4 @@
1
+ from .core import Agent
2
+ from .memory import prune_tool_history
3
+
4
+ __all__ = ["Agent", "prune_tool_history"]
qveris/agent/core.py ADDED
@@ -0,0 +1,415 @@
1
+ """
2
+ Qveris agent runtime.
3
+
4
+ This module defines `Agent`, the high-level orchestration layer that connects:
5
+
6
+ - an LLM provider (`LLMProvider`) capable of emitting tool calls,
7
+ - Qveris built-in tools (`discover`, `inspect`, `call`) exposed to the LLM,
8
+ - optional user-provided tools and a handler for those tools,
9
+ - and an execution loop that feeds tool results back to the LLM until completion.
10
+
11
+ ## Event model
12
+
13
+ `Agent.run(...)` yields `StreamEvent` objects. Depending on the chosen provider and whether
14
+ streaming is enabled, you may see:
15
+
16
+ - `content`: assistant output (delta chunks in streaming mode, whole message in non-streaming)
17
+ - `reasoning`: optional reasoning tokens from some providers
18
+ - `reasoning_details`: optional structured reasoning details (e.g. Gemini thought signatures via OpenRouter)
19
+ - `tool_call`: a tool call the model wants to invoke (OpenAI-compatible tool-call dict)
20
+ - `tool_result`: the result of executing a tool call (Qveris built-ins or your extra tools)
21
+ - `metrics`: token usage / timing metrics if the provider reports them
22
+ - `error`: fatal error that stops the run
23
+
24
+ ## Tool call lifecycle
25
+
26
+ When the LLM requests one or more tool calls:
27
+
28
+ 1. the assistant message (with tool calls) is appended to the conversation,
29
+ 2. each tool is executed in sequence,
30
+ 3. the tool results are appended as `role="tool"` messages,
31
+ 4. the loop continues with the updated conversation.
32
+ """
33
+
34
+ import inspect
35
+ import json
36
+ import uuid
37
+ from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional
38
+
39
+ import httpx
40
+ from openai import APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError, RateLimitError
41
+ from openai.types.chat import ChatCompletionToolParam
42
+
43
+ from ..client.api import QverisClient
44
+ from ..client.tools import CALL_TOOL_DEF, DEFAULT_SYSTEM_PROMPT, DISCOVER_TOOL_DEF, INSPECT_TOOL_DEF
45
+ from ..config import AgentConfig, QverisConfig
46
+ from ..llm.base import LLMProvider
47
+ from ..llm.openai import OpenAIProvider
48
+ from ..types import ChatResponse, Message, StreamEvent
49
+ from .memory import prune_tool_history
50
+
51
+ # Type alias for extra tool handler callback.
52
+ # Called only when the tool call is NOT a built-in Qveris tool.
53
+ ExtraToolHandler = Callable[[str, Dict[str, Any]], Awaitable[Any]]
54
+
55
+ class Agent:
56
+ """
57
+ Qveris agent orchestrator.
58
+
59
+ The agent runs an LLM/tool loop that can:
60
+
61
+ - discover capabilities via Qveris (`discover`),
62
+ - inspect candidate capabilities (`inspect`),
63
+ - call a selected capability (`call`),
64
+ - optionally execute additional user-provided tools (`extra_tools` + `extra_tool_handler`).
65
+
66
+ Parameters:
67
+ config:
68
+ Qveris API / agent runtime configuration (API key, base URL, max iterations, etc.).
69
+ agent_config:
70
+ LLM configuration (model name, temperature, additional system prompt, ...).
71
+ llm_provider:
72
+ Provider implementation that follows `LLMProvider`. If omitted, uses the built-in
73
+ OpenAI-compatible provider (`OpenAIProvider`).
74
+ extra_tools:
75
+ Optional additional tool schemas (OpenAI `ChatCompletionToolParam`) exposed to the LLM.
76
+ These are **not** executed by Qveris unless you also provide `extra_tool_handler`.
77
+ extra_tool_handler:
78
+ Async callback invoked for non-Qveris tool calls. Signature:
79
+ `async def handler(func_name: str, func_args: dict) -> Any`.
80
+ debug_callback:
81
+ Optional callback used by `QverisClient` to emit debug messages (request/response logs,
82
+ with authorization redacted).
83
+
84
+ Notes:
85
+ - A session id is created at construction time; call `new_session()` to reset it.
86
+ - This class is safe to reuse across multiple conversations; pass your own `messages` list.
87
+ """
88
+ def __init__(
89
+ self,
90
+ config: Optional[QverisConfig] = None,
91
+ agent_config: Optional[AgentConfig] = None,
92
+ llm_provider: Optional[LLMProvider] = None,
93
+ extra_tools: Optional[List[ChatCompletionToolParam]] = None,
94
+ extra_tool_handler: Optional[ExtraToolHandler] = None,
95
+ debug_callback: Optional[Callable[[str], None]] = None
96
+ ):
97
+ self.config = config or QverisConfig()
98
+ self.agent_config = agent_config or AgentConfig()
99
+
100
+ # Setup API Client with debug callback
101
+ self.client = QverisClient(self.config, debug_callback=debug_callback)
102
+
103
+ # Setup LLM Provider
104
+ if llm_provider:
105
+ self.llm = llm_provider
106
+ else:
107
+ # Fallback to internal OpenAI provider
108
+ self.llm = OpenAIProvider()
109
+
110
+ self.tools: List[ChatCompletionToolParam] = [DISCOVER_TOOL_DEF, INSPECT_TOOL_DEF, CALL_TOOL_DEF]
111
+ if extra_tools:
112
+ self.tools.extend(extra_tools)
113
+
114
+ # Handler for extra tools not built into Qveris
115
+ self.extra_tool_handler = extra_tool_handler
116
+
117
+ # Setup new session
118
+ self.new_session()
119
+
120
+ self.last_messages: List[Message] = []
121
+
122
+ async def close(self) -> None:
123
+ """
124
+ Close network resources owned by the agent.
125
+
126
+ Call this when you are done with a long-lived `Agent`, or use the agent as an async
127
+ context manager so cleanup happens automatically.
128
+ """
129
+ await self.client.close()
130
+
131
+ close_llm = getattr(self.llm, "close", None)
132
+ if callable(close_llm):
133
+ result = close_llm()
134
+ if inspect.isawaitable(result):
135
+ await result
136
+
137
+ async def __aenter__(self) -> "Agent":
138
+ return self
139
+
140
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
141
+ await self.close()
142
+
143
+ @staticmethod
144
+ def _llm_error_event(error: Exception) -> StreamEvent:
145
+ """Convert provider/client exceptions into user-facing agent error events."""
146
+ if isinstance(error, httpx.TimeoutException):
147
+ return StreamEvent(type="error", error=f"LLM request timed out: {error}")
148
+ if isinstance(error, httpx.ConnectError):
149
+ return StreamEvent(type="error", error=f"Failed to connect to LLM service: {error}")
150
+ if isinstance(error, httpx.HTTPStatusError):
151
+ return StreamEvent(
152
+ type="error",
153
+ error=f"LLM HTTP error {error.response.status_code}: {error.response.text[:200]}"
154
+ )
155
+ if isinstance(error, AuthenticationError):
156
+ return StreamEvent(type="error", error=f"LLM authentication failed: {error}")
157
+ if isinstance(error, RateLimitError):
158
+ return StreamEvent(type="error", error=f"LLM rate limit exceeded: {error}")
159
+ if isinstance(error, (APIConnectionError, APITimeoutError)):
160
+ return StreamEvent(type="error", error=f"LLM connection error: {error}")
161
+ if isinstance(error, APIStatusError):
162
+ return StreamEvent(type="error", error=f"LLM API error {error.status_code}: {error}")
163
+ return StreamEvent(type="error", error=f"LLM error: {error}")
164
+
165
+ def _record_last_messages(self, current_messages: List[Message], inserted_system_prompt: bool) -> None:
166
+ messages_for_caller = current_messages[1:] if inserted_system_prompt else current_messages
167
+ self.last_messages = [message.model_copy(deep=True) for message in messages_for_caller]
168
+
169
+ def get_last_messages(self) -> List[Message]:
170
+ """
171
+ Return the latest conversation history produced by `run(...)`.
172
+
173
+ The returned history includes intermediate assistant tool calls and tool results, plus the
174
+ final assistant content when one was produced. If `run(...)` injected the default system
175
+ prompt, that internal system message is omitted so callers can reuse the list directly.
176
+ """
177
+ return [message.model_copy(deep=True) for message in self.last_messages]
178
+
179
+ async def run(
180
+ self,
181
+ messages: List[Message],
182
+ stream: bool = True
183
+ ) -> AsyncGenerator[StreamEvent, None]:
184
+ """
185
+ Run the agent loop and yield events as they occur.
186
+
187
+ This is the primary integration API. In streaming mode (`stream=True`), the underlying
188
+ provider is expected to yield delta `content` chunks; in non-streaming mode, this method
189
+ yields a single `content` event for the assistant message.
190
+
191
+ Tool calls are always surfaced as `tool_call` events, and tool executions as `tool_result`.
192
+
193
+ Args:
194
+ messages: Conversation history (typically starts with `role="user"`).
195
+ stream: If True, yields content as delta chunks (streaming).
196
+ If False, yields content as complete text (non-streaming).
197
+
198
+ Yields:
199
+ StreamEvent objects for content, reasoning, reasoning_details, tool_call, tool_result,
200
+ metrics, and error.
201
+ """
202
+ # 1. Setup Messages
203
+ current_messages = [m.model_copy() for m in messages]
204
+
205
+ # Add System Prompt
206
+ system_prompt = DEFAULT_SYSTEM_PROMPT
207
+ if self.agent_config.additional_system_prompt:
208
+ system_prompt += '\n' + self.agent_config.additional_system_prompt
209
+
210
+ inserted_system_prompt = False
211
+ if not current_messages or current_messages[0].role != "system":
212
+ current_messages.insert(0, Message(role="system", content=system_prompt))
213
+ inserted_system_prompt = True
214
+ else:
215
+ existing_system_prompt = current_messages[0].content or ""
216
+ if not existing_system_prompt.startswith(system_prompt):
217
+ separator = "\n\n" if existing_system_prompt else ""
218
+ current_messages[0].content = system_prompt + separator + existing_system_prompt
219
+
220
+ self._record_last_messages(current_messages, inserted_system_prompt)
221
+
222
+ iteration = 0
223
+ should_continue = True
224
+ previous_messages_count = len(current_messages)
225
+
226
+ while should_continue and iteration < self.config.max_iterations:
227
+ iteration += 1
228
+
229
+ # Prune history to save tokens if enabled
230
+ messages_to_send = current_messages
231
+ if self.config.enable_history_pruning:
232
+ messages_to_send = prune_tool_history(current_messages, previous_messages_count)
233
+ previous_messages_count = len(messages_to_send)
234
+
235
+ tool_calls: List[Dict[str, Any]] = []
236
+ content_accumulated = ""
237
+ reasoning_details_accumulated = []
238
+
239
+ try:
240
+ if stream:
241
+ # Streaming mode: yield delta chunks
242
+ llm_stream = self.llm.chat_stream(
243
+ messages=messages_to_send,
244
+ tools=self.tools,
245
+ config=self.agent_config
246
+ )
247
+
248
+ async for event in llm_stream:
249
+ if event.type in ["content", "reasoning"]:
250
+ yield event
251
+ if event.type == "content" and event.content:
252
+ content_accumulated += event.content
253
+ elif event.type == "reasoning_details":
254
+ # Accumulate reasoning_details for Gemini thought signatures
255
+ if event.details:
256
+ reasoning_details_accumulated.extend(event.details)
257
+ yield event
258
+ elif event.type == "tool_call":
259
+ tool_calls.append(event.tool_call)
260
+ yield event
261
+ elif event.type == "metrics":
262
+ yield event
263
+ else:
264
+ # Non-streaming mode: get complete response, yield as single event
265
+ response: ChatResponse = await self.llm.chat(
266
+ messages=messages_to_send,
267
+ tools=self.tools,
268
+ config=self.agent_config
269
+ )
270
+
271
+ # Yield complete content as single event
272
+ if response.content:
273
+ content_accumulated = response.content
274
+ yield StreamEvent(type="content", content=response.content)
275
+
276
+ # Capture reasoning_details for Gemini thought signatures
277
+ if response.reasoning_details:
278
+ reasoning_details_accumulated = response.reasoning_details
279
+ yield StreamEvent(type="reasoning_details", details=response.reasoning_details)
280
+
281
+ # Yield tool calls
282
+ if response.tool_calls:
283
+ for tc in response.tool_calls:
284
+ tool_calls.append(tc)
285
+ yield StreamEvent(type="tool_call", tool_call=tc)
286
+
287
+ # Yield metrics
288
+ if response.metrics:
289
+ yield StreamEvent(type="metrics", metrics=response.metrics)
290
+
291
+ except Exception as e:
292
+ self._record_last_messages(current_messages, inserted_system_prompt)
293
+ yield self._llm_error_event(e)
294
+ return
295
+
296
+ # Handle tool calls
297
+ if tool_calls:
298
+ current_messages.append(Message(
299
+ role="assistant",
300
+ content=content_accumulated if content_accumulated else None,
301
+ tool_calls=tool_calls,
302
+ # Preserve reasoning_details for Gemini thought signatures
303
+ reasoning_details=reasoning_details_accumulated if reasoning_details_accumulated else None
304
+ ))
305
+
306
+ for tc in tool_calls:
307
+ func_name = tc["function"]["name"]
308
+ func_args_str = tc["function"]["arguments"]
309
+ call_id = tc["id"]
310
+
311
+ try:
312
+ func_args = json.loads(func_args_str)
313
+ except json.JSONDecodeError:
314
+ error_msg = f"Invalid JSON arguments: {func_args_str}"
315
+ current_messages.append(Message(
316
+ role="tool",
317
+ tool_call_id=call_id,
318
+ name=func_name,
319
+ content=json.dumps({"error": error_msg})
320
+ ))
321
+ yield StreamEvent(
322
+ type="tool_result",
323
+ tool_result={
324
+ "call_id": call_id,
325
+ "name": func_name,
326
+ "result": {"error": error_msg},
327
+ "is_error": True
328
+ }
329
+ )
330
+ continue
331
+
332
+ # Execute Tool
333
+ result, is_error, handled = await self.client.handle_tool_call(
334
+ func_name=func_name,
335
+ func_args=func_args,
336
+ session_id=self.session_id
337
+ )
338
+
339
+ # Handle extra tools if not a built-in Qveris tool
340
+ if not handled:
341
+ if self.extra_tool_handler:
342
+ try:
343
+ result = await self.extra_tool_handler(func_name, func_args)
344
+ is_error = False
345
+ except Exception as e:
346
+ result = {"error": str(e)}
347
+ is_error = True
348
+ else:
349
+ result = {"error": f"Unknown tool: {func_name}"}
350
+ is_error = True
351
+
352
+ # Yield tool_result event
353
+ yield StreamEvent(
354
+ type="tool_result",
355
+ tool_result={
356
+ "call_id": call_id,
357
+ "name": func_name,
358
+ "result": result,
359
+ "is_error": is_error
360
+ }
361
+ )
362
+
363
+ current_messages.append(Message(
364
+ role="tool",
365
+ tool_call_id=call_id,
366
+ name=func_name,
367
+ content=json.dumps(result, default=str)
368
+ ))
369
+
370
+ self._record_last_messages(current_messages, inserted_system_prompt)
371
+ continue
372
+
373
+ else:
374
+ if content_accumulated or reasoning_details_accumulated:
375
+ current_messages.append(Message(
376
+ role="assistant",
377
+ content=content_accumulated if content_accumulated else None,
378
+ reasoning_details=(
379
+ reasoning_details_accumulated if reasoning_details_accumulated else None
380
+ )
381
+ ))
382
+ self._record_last_messages(current_messages, inserted_system_prompt)
383
+ should_continue = False
384
+
385
+ if should_continue:
386
+ self._record_last_messages(current_messages, inserted_system_prompt)
387
+ yield StreamEvent(
388
+ type="error",
389
+ error=f"Agent stopped after reaching max_iterations={self.config.max_iterations}"
390
+ )
391
+
392
+ async def run_to_completion(self, messages: List[Message]) -> str:
393
+ """
394
+ Run the agent in non-streaming mode and return the final assistant text.
395
+
396
+ This is a convenience wrapper around `run(messages, stream=False)` that discards all events
397
+ except `content` and returns the concatenated text.
398
+ """
399
+ content = ""
400
+ async for event in self.run(messages, stream=False):
401
+ if event.type == "content":
402
+ content += event.content or ""
403
+ elif event.type == "error":
404
+ raise RuntimeError(f"Agent execution failed: {event.error}")
405
+ return content
406
+
407
+ def new_session(self) -> str:
408
+ """
409
+ Create and set a new session id.
410
+
411
+ The session id is forwarded to Qveris API calls (discover/call) and can be used server-side
412
+ for correlation, tracing, and analytics.
413
+ """
414
+ self.session_id = str(uuid.uuid4())
415
+ return self.session_id
qveris/agent/memory.py ADDED
@@ -0,0 +1,56 @@
1
+ import json
2
+ from typing import List, Dict, Any, Optional
3
+ from ..types import Message
4
+
5
+ def prune_tool_history(messages: List[Message], previous_messages_count: int) -> List[Message]:
6
+ """
7
+ Collapses old discovery results into concise summaries to save tokens.
8
+
9
+ Args:
10
+ messages: The full message history
11
+ previous_messages_count: The number of previous messages to prune
12
+ """
13
+ # Map tool_call_id -> function_name from assistant messages
14
+ tool_id_to_name = {}
15
+
16
+ # First pass: Build map
17
+ for msg in messages:
18
+ if msg.role == 'assistant' and msg.tool_calls:
19
+ for tc in msg.tool_calls:
20
+ if tc.get('type') == 'function':
21
+ # Handle both dict and object access safely
22
+ func_name = tc.get('function', {}).get('name')
23
+ if not func_name:
24
+ continue
25
+ tool_id_to_name[tc['id']] = func_name
26
+
27
+ new_messages = []
28
+ for i, msg in enumerate(messages):
29
+ if msg.role == 'tool' and msg.tool_call_id and i < previous_messages_count:
30
+ tool_name = tool_id_to_name.get(msg.tool_call_id)
31
+
32
+ if tool_name in {'discover', 'search_tools'} and msg.content:
33
+ try:
34
+ content_obj = json.loads(msg.content)
35
+ # If it has 'results' array, it's the full uncompressed result
36
+ if isinstance(content_obj, dict) and 'results' in content_obj and isinstance(content_obj['results'], list):
37
+ # Create filtered content
38
+ tool_ids = [r.get('tool_id') or r.get('id') for r in content_obj['results']]
39
+ search_id = content_obj.get('search_id')
40
+
41
+ filtered_content = json.dumps({
42
+ "tool_ids": tool_ids,
43
+ "search_id": search_id
44
+ })
45
+
46
+ # Create new message with filtered content
47
+ new_msg = msg.model_copy()
48
+ new_msg.content = filtered_content
49
+ new_messages.append(new_msg)
50
+ continue
51
+ except json.JSONDecodeError:
52
+ pass
53
+
54
+ new_messages.append(msg)
55
+
56
+ return new_messages
@@ -0,0 +1,21 @@
1
+ from .api import QverisClient
2
+ from .tools import (
3
+ CALL_TOOL_DEF,
4
+ DEFAULT_SYSTEM_PROMPT,
5
+ DISCOVER_TOOL_DEF,
6
+ EXECUTE_TOOL_DEF,
7
+ GET_TOOLS_BY_IDS_TOOL_DEF,
8
+ INSPECT_TOOL_DEF,
9
+ SEARCH_TOOL_DEF,
10
+ )
11
+
12
+ __all__ = [
13
+ "QverisClient",
14
+ "DEFAULT_SYSTEM_PROMPT",
15
+ "DISCOVER_TOOL_DEF",
16
+ "INSPECT_TOOL_DEF",
17
+ "CALL_TOOL_DEF",
18
+ "SEARCH_TOOL_DEF",
19
+ "GET_TOOLS_BY_IDS_TOOL_DEF",
20
+ "EXECUTE_TOOL_DEF",
21
+ ]