truefoundry 0.9.0rc1__py3-none-any.whl → 0.9.2rc1__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.

Potentially problematic release.


This version of truefoundry might be problematic. Click here for more details.

@@ -1,21 +1,29 @@
1
1
  try:
2
2
  from mcp import ClientSession
3
3
  from mcp.client.streamable_http import streamablehttp_client
4
- from mcp.types import TextContent
4
+ from mcp.types import GetPromptResult, TextContent
5
5
  except ImportError:
6
6
  import sys
7
7
 
8
+ message_parts = [
9
+ r'This feature requires the `ai` extra. Please install it using `pip install -U "truefoundry\[ai]"`.',
10
+ "Note: This feature requires Python 3.10 or higher.",
11
+ ]
8
12
  python_version = sys.version_info
9
- raise ImportError(
10
- f"This feature requires Python 3.10 or higher. Your current Python version is '{python_version.major}.{python_version.minor}.{python_version.micro}'. "
11
- "Please upgrade to a supported version."
12
- ) from None
13
+ if python_version.major < 3 or python_version.minor < 10:
14
+ message_parts.append(
15
+ f"Your current Python version is '{python_version.major}.{python_version.minor}'."
16
+ )
17
+ raise ImportError("\n".join(message_parts)) from None
13
18
 
14
19
  import json
20
+ import uuid
15
21
  from contextlib import AsyncExitStack
16
- from typing import List, Optional, Union
22
+ from datetime import datetime
23
+ from typing import Any, Dict, List, Optional, Tuple, Union
17
24
 
18
25
  import rich_click as click
26
+ import yaml
19
27
  from openai import NOT_GIVEN, AsyncOpenAI
20
28
  from openai.types.chat import (
21
29
  ChatCompletionAssistantMessageParam,
@@ -29,13 +37,29 @@ from pydantic import BaseModel
29
37
  from rich.console import Console
30
38
  from rich.status import Status
31
39
 
32
- from truefoundry.cli.display_util import log_chat_completion_message
33
- from truefoundry.common.constants import ENV_VARS
40
+ from truefoundry._ask.llm_utils import (
41
+ log_chat_completion_message,
42
+ translate_tools_for_gemini,
43
+ )
44
+ from truefoundry.common.constants import ENV_VARS, TFY_CONFIG_DIR
34
45
  from truefoundry.logger import logger
35
46
 
36
47
  console = Console(soft_wrap=False)
37
48
 
38
49
 
50
+ def _get_content(data: Any) -> Optional[Any]:
51
+ content = None
52
+ if isinstance(data, dict):
53
+ content = (
54
+ data.get("content", {}).get("text")
55
+ if isinstance(data.get("content"), dict)
56
+ else data.get("content")
57
+ )
58
+ else:
59
+ content = data
60
+ return content
61
+
62
+
39
63
  class AskClient:
40
64
  """Handles the chat session lifecycle between the user and the assistant via OpenAI and MCP."""
41
65
 
@@ -52,77 +76,221 @@ class AskClient:
52
76
  self.debug = debug
53
77
 
54
78
  self.async_openai_client = openai_client or AsyncOpenAI()
55
- # Initialize the OpenAI client with the session
56
79
  self.openai_model = openai_model
80
+ self.generation_params: Dict[str, Any] = dict(
81
+ ENV_VARS.TFY_ASK_GENERATION_PARAMS
82
+ )
57
83
  self._log_message(
58
- f"\nInitialize OpenAI client with model: {self.openai_model!r}, base_url: {str(self.async_openai_client.base_url)!r}\n"
84
+ f"\nInitialized with model: {self.openai_model!r}, "
85
+ f"generation_params: {self.generation_params!r}, "
86
+ f"base_url: {str(self.async_openai_client.base_url)!r}\n",
59
87
  )
60
-
61
- self.exit_stack = AsyncExitStack()
62
88
  self.history: List[ChatCompletionMessageParam] = []
63
89
  self._cached_tools: Optional[List[ChatCompletionToolParam]] = None
90
+ self._session_id = str(uuid.uuid4())
91
+ self._prompt_length = 0
64
92
 
65
- async def connect(self, server_url: str, prompt_name: Optional[str] = None):
66
- """Initialize connection to the SSE-based MCP server and prepare the chat session."""
67
- try:
68
- logger.debug(f"Starting a new client for {server_url}")
69
- self._streams_context = streamablehttp_client(
70
- url=server_url, headers=self._auth_headers()
71
- )
72
- (
73
- read_stream,
74
- write_stream,
75
- _,
76
- ) = await self._streams_context.__aenter__()
77
- self._session_context = ClientSession(
78
- read_stream=read_stream, write_stream=write_stream
93
+ def _auth_headers(self):
94
+ """Generate authorization headers for connecting to the SSE server."""
95
+ return {
96
+ "Authorization": f"Bearer {self.token}",
97
+ "X-TFY-Cluster-Id": self.cluster,
98
+ "X-TFY-Session-Id": self._session_id,
99
+ }
100
+
101
+ def _format_tool_result(self, content) -> str:
102
+ """Format tool result into a readable string or JSON block."""
103
+ if isinstance(content, list):
104
+ content = (
105
+ content[0].text
106
+ if len(content) == 1 and isinstance(content[0], TextContent)
107
+ else content
79
108
  )
80
- self.session = await self._session_context.__aenter__()
81
- await self.session.initialize()
82
- self._log_message("Connected and session initialized.")
83
- await self._list_tools() # Pre-load tool definitions for tool-calling
84
- self._log_message(
85
- "\nTFY ASK is ready. Type 'exit' to quit.", log=self.debug
109
+ if isinstance(content, list):
110
+ return (
111
+ "```\n"
112
+ + "\n".join(
113
+ (
114
+ item.model_dump_json(indent=2)
115
+ if isinstance(item, BaseModel)
116
+ else str(item)
117
+ )
118
+ for item in content
119
+ )
120
+ + "\n```"
121
+ )
122
+
123
+ if isinstance(content, (BaseModel, dict)):
124
+ return (
125
+ "```\n"
126
+ + json.dumps(
127
+ content.model_dump() if isinstance(content, BaseModel) else content,
128
+ indent=2,
129
+ )
130
+ + "\n```"
86
131
  )
87
132
 
88
- await self._load_initial_prompt(prompt_name)
133
+ if isinstance(content, str):
134
+ try:
135
+ return "```\n" + json.dumps(json.loads(content), indent=2) + "\n```"
136
+ except Exception:
137
+ return content
89
138
 
90
- except Exception as e:
91
- self._log_message(f"❌ Connection error: {e}")
92
- await self.cleanup()
93
- raise
94
- finally:
95
- await self.exit_stack.__aenter__()
139
+ return str(content)
96
140
 
97
- async def cleanup(self):
98
- """Properly close all async contexts opened during session initialization."""
99
- for context in [
100
- getattr(self, "_session_context", None),
101
- getattr(self, "_streams_context", None),
102
- ]:
103
- if context:
104
- await context.__aexit__(None, None, None)
141
+ def _append_message(self, message: ChatCompletionMessageParam, log: bool = True):
142
+ """Append a message to history and optionally log it."""
143
+ self._log_message(message, log)
144
+ self.history.append(message)
105
145
 
106
- async def chat_loop(self):
107
- """Interactive loop: accepts user queries and returns responses until interrupted or 'exit' is typed."""
108
- await self.process_query() # Optional greeting message from assistant
146
+ def _log_message(
147
+ self,
148
+ message: Union[str, ChatCompletionMessageParam],
149
+ log: bool = False,
150
+ ):
151
+ """Display a message using Rich console, conditionally based on debug settings."""
152
+ if not self.debug and not log:
153
+ return
154
+ if isinstance(message, str):
155
+ console.print(message)
156
+ else:
157
+ log_chat_completion_message(message, console=console)
109
158
 
110
- while True:
111
- try:
112
- query = click.prompt(click.style("User", fg="yellow"), type=str)
113
- if not query:
114
- self._log_message("Empty query. Type 'exit' to quit.", log=True)
115
- continue
159
+ def _maybe_save_transcript(self):
160
+ """Save the transcript to a file."""
161
+ if not click.confirm("Save the chat transcript to a file?", default=True):
162
+ return
163
+ transcripts_dir = TFY_CONFIG_DIR / "tfy-ask-transcripts"
164
+ transcripts_dir.mkdir(parents=True, exist_ok=True)
165
+ transcript_file = (
166
+ transcripts_dir
167
+ / f"chat-{self._session_id}-{datetime.now().strftime('%Y-%m-%dT%H-%M-%S')}.json"
168
+ )
116
169
 
117
- if query.lower() in ("exit", "quit"):
118
- self._log_message("Exiting chat...")
119
- break
170
+ def _custom_encoder(obj):
171
+ if isinstance(obj, datetime):
172
+ return obj.isoformat()
173
+ elif isinstance(obj, BaseModel):
174
+ return obj.model_dump()
175
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
176
+
177
+ data = {
178
+ "cluster": self.cluster,
179
+ "model": self.openai_model,
180
+ "messages": self.history,
181
+ }
182
+ try:
183
+ with open(transcript_file, "w") as f:
184
+ json.dump(data, f, indent=2, default=_custom_encoder)
185
+ console.print(f"Chat transcript saved to {transcript_file}")
186
+ except (IOError, PermissionError) as e:
187
+ console.print(f"[red]Failed to save transcript: {e}[/red]")
188
+
189
+ def _load_config_from_file(
190
+ self, filepath: str
191
+ ) -> Tuple[Optional[GetPromptResult], str, Dict[str, Any]]:
192
+ self._log_message(
193
+ f"Loading initial prompt and params from file {filepath}.", log=True
194
+ )
195
+ with open(filepath, "r") as f:
196
+ content = yaml.safe_load(f)
197
+ if "prompt" in content:
198
+ prompt_result = GetPromptResult.model_validate(content["prompt"])
199
+ else:
200
+ prompt_result = None
201
+ model = content.get("model") or self.openai_model
202
+ generation_params = (
203
+ content.get("generation_params", {}) or self.generation_params
204
+ )
205
+ return prompt_result, model, generation_params
120
206
 
121
- await self.process_query(query)
207
+ def _maybe_reload_prompt_and_params(self):
208
+ if ENV_VARS.TFY_INTERNAL_ASK_CONFIG_OVERRIDE_FILE:
209
+ prompt_result, model, generation_params = self._load_config_from_file(
210
+ filepath=ENV_VARS.TFY_INTERNAL_ASK_CONFIG_OVERRIDE_FILE
211
+ )
212
+ self.openai_model = model
213
+ self.generation_params = generation_params
214
+ if prompt_result:
215
+ message_index = 0
216
+ for message in prompt_result.messages:
217
+ data = (
218
+ message.model_dump()
219
+ if isinstance(message, BaseModel)
220
+ else message
221
+ )
222
+ content = _get_content(data)
223
+ # First message is system prompt?
224
+ if content:
225
+ if message_index < self._prompt_length:
226
+ self.history[message_index] = (
227
+ ChatCompletionSystemMessageParam(
228
+ role="system", content=content
229
+ )
230
+ )
231
+ else:
232
+ # insert just before self._prompt_length
233
+ self.history.insert(
234
+ self._prompt_length,
235
+ ChatCompletionSystemMessageParam(
236
+ role="system", content=content
237
+ ),
238
+ )
239
+ message_index += 1
240
+ # Remove any leftover old prompt lines
241
+ del self.history[message_index : self._prompt_length]
242
+ self._prompt_length = message_index
243
+ self._log_message(
244
+ f"Reloaded initial prompt and params from {ENV_VARS.TFY_INTERNAL_ASK_CONFIG_OVERRIDE_FILE}.",
245
+ log=True,
246
+ )
122
247
 
123
- except (KeyboardInterrupt, EOFError, click.Abort):
124
- self._log_message("\nChat interrupted.")
125
- break
248
+ async def _call_openai(self, tools: Optional[List[ChatCompletionToolParam]]):
249
+ """Make a chat completion request to OpenAI with optional tool support."""
250
+ return await self.async_openai_client.chat.completions.create(
251
+ model=self.openai_model,
252
+ messages=self.history,
253
+ tools=tools or NOT_GIVEN,
254
+ extra_headers={
255
+ "X-TFY-METADATA": json.dumps(
256
+ {
257
+ "tfy_log_request": "true",
258
+ "session_id": self._session_id,
259
+ }
260
+ )
261
+ },
262
+ **self.generation_params,
263
+ )
264
+
265
+ async def _handle_tool_calls(self, message, spinner: Status):
266
+ """Execute tool calls returned by the assistant and return the results."""
267
+ for tool_call in message.tool_calls:
268
+ try:
269
+ spinner.update(
270
+ f"Executing tool: {tool_call.function.name}", spinner="aesthetic"
271
+ )
272
+ args = json.loads(tool_call.function.arguments)
273
+ result = await self.session.call_tool(tool_call.function.name, args)
274
+ content = getattr(result, "content", result)
275
+ result_content = self._format_tool_result(content)
276
+ except Exception as e:
277
+ result_content = f"Tool `{tool_call.function.name}` call failed: {e}"
278
+
279
+ # Log assistant's tool call
280
+ self._append_message(
281
+ ChatCompletionAssistantMessageParam(
282
+ role="assistant", content=None, tool_calls=[tool_call]
283
+ ),
284
+ log=self.debug,
285
+ )
286
+
287
+ # Log tool response
288
+ self._append_message(
289
+ ChatCompletionToolMessageParam(
290
+ role="tool", tool_call_id=tool_call.id, content=result_content
291
+ ),
292
+ log=self.debug,
293
+ )
126
294
 
127
295
  async def process_query(self, query: Optional[str] = None, max_turns: int = 50):
128
296
  """Handles sending user input to the assistant and processing the assistant’s reply."""
@@ -142,35 +310,33 @@ class AskClient:
142
310
  while True:
143
311
  try:
144
312
  if turn >= max_turns:
145
- self._log_message("Max turns reached. Exiting.")
313
+ self._log_message("Max turns reached. Exiting.", log=True)
146
314
  break
147
315
  spinner.update("Thinking...", spinner="dots")
148
- response = await self._call_openai(
149
- model=self.openai_model, tools=tools
150
- )
316
+ response = await self._call_openai(tools=tools)
151
317
  turn += 1
152
318
  message = response.choices[0].message
153
319
 
154
- if message.tool_calls:
155
- await self._handle_tool_calls(message, spinner)
156
- elif message.content:
320
+ if message.content:
157
321
  self._append_message(
158
322
  ChatCompletionAssistantMessageParam(
159
323
  role="assistant", content=message.content
160
324
  )
161
325
  )
162
- break
163
- else:
326
+
327
+ if message.tool_calls:
328
+ await self._handle_tool_calls(message, spinner)
329
+
330
+ if not message.content and not message.tool_calls:
164
331
  self._log_message("No assistant response.")
165
332
  break
166
333
  except Exception as e:
167
- self._log_message(f"OpenAI call failed: {e}", log=self.debug)
334
+ self._log_message(f"OpenAI call failed: {e}", log=True)
168
335
  console.print(
169
336
  "Something went wrong. Please try rephrasing your query."
170
337
  )
171
- self.history = self.history[
172
- :_checkpoint_idx
173
- ] # Revert to safe state
338
+ # Revert to safe state
339
+ self.history = self.history[:_checkpoint_idx]
174
340
  turn = 0
175
341
  break
176
342
 
@@ -179,22 +345,27 @@ class AskClient:
179
345
  if self._cached_tools:
180
346
  return self._cached_tools
181
347
 
182
- self._cached_tools = [
348
+ tools: List[ChatCompletionToolParam] = [
183
349
  {
184
350
  "type": "function",
185
351
  "function": {
186
352
  "name": t.name,
187
- "description": t.description,
353
+ "description": t.description or "",
188
354
  "parameters": t.inputSchema,
189
355
  },
190
356
  }
191
357
  for t in (await self.session.list_tools()).tools
192
358
  ]
193
359
 
360
+ if "gemini" in self.openai_model.lower():
361
+ tools = translate_tools_for_gemini(tools) or []
362
+
363
+ self._cached_tools = tools
364
+
194
365
  self._log_message("\nAvailable tools:")
195
366
  for tool in self._cached_tools or []:
196
367
  self._log_message(
197
- f" - {tool['function']['name']}: {tool['function']['description']}"
368
+ f" - {tool['function']['name']}: {tool['function']['description'] or ''}"
198
369
  )
199
370
  return self._cached_tools
200
371
 
@@ -203,140 +374,87 @@ class AskClient:
203
374
  if not (self.session and prompt_name):
204
375
  return
205
376
 
206
- result = await self.session.get_prompt(name=prompt_name)
207
- if not result:
377
+ prompt_result = None
378
+ if ENV_VARS.TFY_INTERNAL_ASK_CONFIG_OVERRIDE_FILE:
379
+ prompt_result, model, generation_params = self._load_config_from_file(
380
+ filepath=ENV_VARS.TFY_INTERNAL_ASK_CONFIG_OVERRIDE_FILE
381
+ )
382
+ self.openai_model = model
383
+ self.generation_params = generation_params
384
+
385
+ if not prompt_result:
386
+ prompt_result = await self.session.get_prompt(name=prompt_name)
387
+
388
+ if not prompt_result:
208
389
  self._log_message("Failed to get initial system prompt.")
209
390
  return
210
391
 
211
- for message in result.messages:
392
+ for message in prompt_result.messages:
212
393
  data = message.model_dump() if isinstance(message, BaseModel) else message
213
- content = None
214
- if isinstance(data, dict):
215
- content = (
216
- data.get("content", {}).get("text")
217
- if isinstance(data.get("content"), dict)
218
- else data.get("content")
219
- )
220
- else:
221
- content = data
222
-
394
+ content = _get_content(data)
223
395
  # First message is system prompt?
224
-
225
396
  if content:
226
397
  self._append_message(
227
398
  ChatCompletionSystemMessageParam(role="system", content=content),
228
399
  log=self.debug,
229
400
  )
401
+ self._prompt_length = len(self.history)
230
402
 
231
- async def _call_openai(
232
- self, model: str, tools: Optional[List[ChatCompletionToolParam]]
233
- ):
234
- """Make a chat completion request to OpenAI with optional tool support."""
235
- return await self.async_openai_client.chat.completions.create(
236
- model=model,
237
- messages=self.history,
238
- tools=tools or NOT_GIVEN,
239
- temperature=0.0, # Set to 0 for deterministic behavior
240
- top_p=1,
403
+ async def chat_loop(self):
404
+ """Interactive loop: accepts user queries and returns responses until interrupted or 'exit' is typed."""
405
+ self._append_message(
406
+ ChatCompletionAssistantMessageParam(
407
+ role="assistant",
408
+ content="Hello! How can I help you with your Kubernetes cluster?",
409
+ )
241
410
  )
242
411
 
243
- async def _handle_tool_calls(self, message, spinner: Status):
244
- """Execute tool calls returned by the assistant and return the results."""
245
- for tool_call in message.tool_calls:
412
+ while True:
246
413
  try:
247
- spinner.update(
248
- f"Executing tool: {tool_call.function.name}", spinner="aesthetic"
249
- )
250
- args = json.loads(tool_call.function.arguments)
251
- result = await self.session.call_tool(tool_call.function.name, args)
252
- content = getattr(result, "content", result)
253
- result_content = self._format_tool_result(content)
254
- except Exception as e:
255
- result_content = f"Tool `{tool_call.function.name}` call failed: {e}"
256
-
257
- # Log assistant's tool call
258
- self._append_message(
259
- ChatCompletionAssistantMessageParam(
260
- role="assistant", content=None, tool_calls=[tool_call]
261
- ),
262
- log=self.debug,
263
- )
414
+ query = click.prompt(click.style("User", fg="yellow"), type=str)
415
+ if not query:
416
+ self._log_message("Empty query. Type 'exit' to quit.", log=True)
417
+ continue
264
418
 
265
- # Log tool response
266
- self._append_message(
267
- ChatCompletionToolMessageParam(
268
- role="tool", tool_call_id=tool_call.id, content=result_content
269
- ),
270
- log=self.debug,
271
- )
419
+ if query.lower() in ("exit", "quit"):
420
+ self._log_message("Exiting chat...")
421
+ self._maybe_save_transcript()
422
+ break
272
423
 
273
- def _format_tool_result(self, content) -> str:
274
- """Format tool result into a readable string or JSON block."""
275
- if isinstance(content, list):
276
- content = (
277
- content[0].text
278
- if len(content) == 1 and isinstance(content[0], TextContent)
279
- else content
280
- )
281
- if isinstance(content, list):
282
- return (
283
- "```\n"
284
- + "\n".join(
285
- (
286
- item.model_dump_json(indent=2)
287
- if isinstance(item, BaseModel)
288
- else str(item)
289
- )
290
- for item in content
291
- )
292
- + "\n```"
293
- )
424
+ self._maybe_reload_prompt_and_params()
425
+ await self.process_query(query)
294
426
 
295
- if isinstance(content, (BaseModel, dict)):
296
- return (
297
- "```\n"
298
- + json.dumps(
299
- content.model_dump() if isinstance(content, BaseModel) else content,
300
- indent=2,
301
- )
302
- + "\n```"
303
- )
427
+ except (KeyboardInterrupt, EOFError, click.Abort):
428
+ self._log_message("\nChat interrupted. Exiting chat...")
429
+ self._maybe_save_transcript()
430
+ break
304
431
 
305
- if isinstance(content, str):
432
+ async def start_session(self, server_url: str, prompt_name: Optional[str] = None):
433
+ """Initialize connection to the Streamable HTTP MCP server and prepare the chat session."""
434
+ logger.debug(f"Starting a new client for {server_url}")
435
+ async with AsyncExitStack() as exit_stack:
306
436
  try:
307
- return "```\n" + json.dumps(json.loads(content), indent=2) + "\n```"
308
- except Exception:
309
- return content
310
-
311
- return str(content)
312
-
313
- def _append_message(self, message: ChatCompletionMessageParam, log: bool = True):
314
- """Append a message to history and optionally log it."""
315
- self._log_message(message, log)
316
- self.history.append(message)
317
-
318
- def _auth_headers(self):
319
- """Generate authorization headers for connecting to the SSE server."""
320
- return {
321
- "Authorization": f"Bearer {self.token}",
322
- "X-TFY-Cluster-Id": self.cluster,
323
- }
324
-
325
- def _log_message(
326
- self,
327
- message: Union[str, ChatCompletionMessageParam],
328
- log: bool = False,
329
- ):
330
- """Display a message using Rich console, conditionally based on debug settings."""
331
- if not self.debug and not log:
332
- return
333
- if isinstance(message, str):
334
- console.print(message)
335
- else:
336
- log_chat_completion_message(message, console_=console)
437
+ read_stream, write_stream, _ = await exit_stack.enter_async_context(
438
+ streamablehttp_client(url=server_url, headers=self._auth_headers())
439
+ )
440
+ self.session = await exit_stack.enter_async_context(
441
+ ClientSession(read_stream=read_stream, write_stream=write_stream)
442
+ )
443
+ await self.session.initialize()
444
+ except Exception as e:
445
+ self._log_message(f"❌ Connection error: {e}", log=True)
446
+ raise
447
+ else:
448
+ self._log_message(f"✅ Connected to {server_url}")
449
+ await self._list_tools()
450
+ await self._load_initial_prompt(prompt_name)
451
+ self._log_message(
452
+ "\n[dim]Tip: Type 'exit' to quit chat.[/dim]", log=True
453
+ )
454
+ await self.chat_loop()
337
455
 
338
456
 
339
- async def ask_client_main(
457
+ async def ask_client(
340
458
  cluster: str,
341
459
  server_url: str,
342
460
  token: str,
@@ -353,15 +471,12 @@ async def ask_client_main(
353
471
  openai_model=openai_model,
354
472
  )
355
473
  try:
356
- await ask_client.connect(
474
+ await ask_client.start_session(
357
475
  server_url=server_url, prompt_name=ENV_VARS.TFY_ASK_SYSTEM_PROMPT_NAME
358
476
  )
359
- await ask_client.chat_loop()
477
+ except KeyboardInterrupt:
478
+ console.print("[yellow]Chat interrupted.[/yellow]")
360
479
  except Exception as e:
361
480
  console.print(
362
- f"[red]An unexpected error occurred while running the assistant: {e}[/red], Check with TrueFoundry support for more details."
481
+ f"[red]An unexpected error occurred while running the assistant: {e}[/red]\nCheck with TrueFoundry support for more details."
363
482
  )
364
- except KeyboardInterrupt:
365
- console.print("[yellow]Chat interrupted.[/yellow]")
366
- finally:
367
- await ask_client.cleanup()