npcpy 1.2.35__tar.gz → 1.2.36__tar.gz

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 (73) hide show
  1. {npcpy-1.2.35/npcpy.egg-info → npcpy-1.2.36}/PKG-INFO +1 -1
  2. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/npc_compiler.py +50 -17
  3. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/serve.py +523 -77
  4. {npcpy-1.2.35 → npcpy-1.2.36/npcpy.egg-info}/PKG-INFO +1 -1
  5. {npcpy-1.2.35 → npcpy-1.2.36}/setup.py +1 -1
  6. {npcpy-1.2.35 → npcpy-1.2.36}/LICENSE +0 -0
  7. {npcpy-1.2.35 → npcpy-1.2.36}/MANIFEST.in +0 -0
  8. {npcpy-1.2.35 → npcpy-1.2.36}/README.md +0 -0
  9. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/__init__.py +0 -0
  10. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/__init__.py +0 -0
  11. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/audio.py +0 -0
  12. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/data_models.py +0 -0
  13. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/image.py +0 -0
  14. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/load.py +0 -0
  15. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/text.py +0 -0
  16. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/video.py +0 -0
  17. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/data/web.py +0 -0
  18. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/__init__.py +0 -0
  19. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/diff.py +0 -0
  20. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/ge.py +0 -0
  21. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/memory_trainer.py +0 -0
  22. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/model_ensembler.py +0 -0
  23. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/rl.py +0 -0
  24. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/sft.py +0 -0
  25. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/ft/usft.py +0 -0
  26. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/__init__.py +0 -0
  27. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/audio_gen.py +0 -0
  28. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/embeddings.py +0 -0
  29. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/image_gen.py +0 -0
  30. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/ocr.py +0 -0
  31. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/response.py +0 -0
  32. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/gen/video_gen.py +0 -0
  33. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/llm_funcs.py +0 -0
  34. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/main.py +0 -0
  35. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/__init__.py +0 -0
  36. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/command_history.py +0 -0
  37. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/kg_vis.py +0 -0
  38. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/knowledge_graph.py +0 -0
  39. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/memory_processor.py +0 -0
  40. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/memory/search.py +0 -0
  41. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/mix/__init__.py +0 -0
  42. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/mix/debate.py +0 -0
  43. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/npc_sysenv.py +0 -0
  44. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/npcs.py +0 -0
  45. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/__init__.py +0 -0
  46. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/ai_function_tools.py +0 -0
  47. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/database_ai_adapters.py +0 -0
  48. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/database_ai_functions.py +0 -0
  49. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/model_runner.py +0 -0
  50. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/npcsql.py +0 -0
  51. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/sql/sql_model_compiler.py +0 -0
  52. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/tools.py +0 -0
  53. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/work/__init__.py +0 -0
  54. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/work/desktop.py +0 -0
  55. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/work/plan.py +0 -0
  56. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy/work/trigger.py +0 -0
  57. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy.egg-info/SOURCES.txt +0 -0
  58. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy.egg-info/dependency_links.txt +0 -0
  59. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy.egg-info/requires.txt +0 -0
  60. {npcpy-1.2.35 → npcpy-1.2.36}/npcpy.egg-info/top_level.txt +0 -0
  61. {npcpy-1.2.35 → npcpy-1.2.36}/setup.cfg +0 -0
  62. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_audio.py +0 -0
  63. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_command_history.py +0 -0
  64. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_image.py +0 -0
  65. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_llm_funcs.py +0 -0
  66. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_load.py +0 -0
  67. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_npc_compiler.py +0 -0
  68. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_npcsql.py +0 -0
  69. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_response.py +0 -0
  70. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_serve.py +0 -0
  71. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_text.py +0 -0
  72. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_tools.py +0 -0
  73. {npcpy-1.2.35 → npcpy-1.2.36}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.2.35
3
+ Version: 1.2.36
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -619,6 +619,35 @@ def load_jinxs_from_directory(directory):
619
619
 
620
620
  return jinxs
621
621
 
622
+ def jinx_to_tool_def(jinx_obj: 'Jinx') -> Dict[str, Any]:
623
+ """Convert a Jinx instance into an MCP/LLM-compatible tool schema definition."""
624
+ properties: Dict[str, Any] = {}
625
+ required: List[str] = []
626
+ for inp in jinx_obj.inputs:
627
+ if isinstance(inp, str):
628
+ properties[inp] = {"type": "string"}
629
+ required.append(inp)
630
+ elif isinstance(inp, dict):
631
+ name = list(inp.keys())[0]
632
+ properties[name] = {"type": "string", "default": inp.get(name, "")}
633
+ required.append(name)
634
+ return {
635
+ "type": "function",
636
+ "function": {
637
+ "name": jinx_obj.jinx_name,
638
+ "description": jinx_obj.description or f"Jinx: {jinx_obj.jinx_name}",
639
+ "parameters": {
640
+ "type": "object",
641
+ "properties": properties,
642
+ "required": required
643
+ }
644
+ }
645
+ }
646
+
647
+ def build_jinx_tool_catalog(jinxs: Dict[str, 'Jinx']) -> Dict[str, Dict[str, Any]]:
648
+ """Helper to build a name->tool_def catalog from a dict of Jinx objects."""
649
+ return {name: jinx_to_tool_def(jinx_obj) for name, jinx_obj in jinxs.items()}
650
+
622
651
  def get_npc_action_space(npc=None, team=None):
623
652
  """Get action space for NPC including memory CRUD and core capabilities"""
624
653
  actions = DEFAULT_ACTION_SPACE.copy()
@@ -851,6 +880,8 @@ class NPC:
851
880
  self.tools_schema = []
852
881
  self.plain_system_message = plain_system_message
853
882
  self.use_global_jinxs = use_global_jinxs
883
+ self.jinx_tool_catalog: Dict[str, Dict[str, Any]] = {}
884
+ self.mcp_servers = []
854
885
 
855
886
  self.memory_length = 20
856
887
  self.memory_strategy = 'recent'
@@ -886,20 +917,20 @@ class NPC:
886
917
  # If jinxs are explicitly provided *to the NPC* during its standalone creation, load them.
887
918
  # This is for NPCs created *outside* a team context initially.
888
919
  if jinxs and jinxs != "*":
889
- for jinx_item in jinxs:
890
- if isinstance(jinx_item, Jinx):
891
- self.jinxs_dict[jinx_item.jinx_name] = jinx_item
892
- elif isinstance(jinx_item, dict):
893
- jinx_obj = Jinx(jinx_data=jinx_item)
894
- self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
895
- elif isinstance(jinx_item, str):
896
- # Try to load from NPC's own directory first
897
- jinx_path = find_file_path(jinx_item, [self.npc_jinxs_directory], suffix=".jinx")
898
- if jinx_path:
899
- jinx_obj = Jinx(jinx_path=jinx_path)
900
- self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
901
- else:
902
- print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
920
+ for jinx_item in jinxs:
921
+ if isinstance(jinx_item, Jinx):
922
+ self.jinxs_dict[jinx_item.jinx_name] = jinx_item
923
+ elif isinstance(jinx_item, dict):
924
+ jinx_obj = Jinx(jinx_data=jinx_item)
925
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
926
+ elif isinstance(jinx_item, str):
927
+ # Try to load from NPC's own directory first
928
+ jinx_path = find_file_path(jinx_item, [self.npc_jinxs_directory], suffix=".jinx")
929
+ if jinx_path:
930
+ jinx_obj = Jinx(jinx_path=jinx_path)
931
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
932
+ else:
933
+ print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
903
934
 
904
935
  self.shared_context = {
905
936
  "dataframes": {},
@@ -977,7 +1008,8 @@ class NPC:
977
1008
  except Exception as e:
978
1009
  print(f"Error performing first-pass rendering for NPC Jinx '{raw_npc_jinx.jinx_name}': {e}")
979
1010
 
980
- print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs.")
1011
+ self.jinx_tool_catalog = build_jinx_tool_catalog(self.jinxs_dict)
1012
+ print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs and built catalog with {len(self.jinx_tool_catalog)} tools.")
981
1013
 
982
1014
  def _load_npc_kg(self):
983
1015
  """Load knowledge graph data for this NPC from database"""
@@ -2004,6 +2036,7 @@ class Team:
2004
2036
  self.sub_teams: Dict[str, 'Team'] = {}
2005
2037
  self.jinxs_dict: Dict[str, 'Jinx'] = {} # This will store first-pass rendered Jinx objects
2006
2038
  self._raw_jinxs_list: List['Jinx'] = [] # Temporary storage for raw Team-level Jinx objects
2039
+ self.jinx_tool_catalog: Dict[str, Dict[str, Any]] = {} # Jinx-derived tool defs ready for MCP/LLM
2007
2040
 
2008
2041
  self.jinja_env_for_first_pass = Environment(undefined=SilentUndefined) # Env for macro expansion
2009
2042
 
@@ -2059,6 +2092,8 @@ class Team:
2059
2092
 
2060
2093
  # Perform first-pass rendering for team-level jinxs
2061
2094
  self._perform_first_pass_jinx_rendering()
2095
+ self.jinx_tool_catalog = build_jinx_tool_catalog(self.jinxs_dict)
2096
+ print(f"[TEAM] Built Jinx tool catalog with {len(self.jinx_tool_catalog)} entries for team {self.name}")
2062
2097
 
2063
2098
  # Now, initialize jinxs for all NPCs, as team-level jinxs are ready
2064
2099
  for npc_obj in self.npcs.values():
@@ -2236,8 +2271,6 @@ class Team:
2236
2271
  self.jinxs_dict[raw_jinx.jinx_name] = raw_jinx # Store the first-pass rendered Jinx
2237
2272
  except Exception as e:
2238
2273
  print(f"Error performing first-pass rendering for Jinx '{raw_jinx.jinx_name}': {e}")
2239
-
2240
- self._raw_jinxs_list = [] # Clear temporary storage
2241
2274
 
2242
2275
 
2243
2276
  def update_context(self, messages: list):
@@ -9,6 +9,9 @@ import traceback
9
9
  import glob
10
10
  import re
11
11
  import time
12
+ import asyncio
13
+ from typing import Optional, List, Dict, Callable, Any
14
+ from contextlib import AsyncExitStack
12
15
 
13
16
  import io
14
17
  from flask_cors import CORS
@@ -18,6 +21,8 @@ import json
18
21
  from pathlib import Path
19
22
  import yaml
20
23
  from dotenv import load_dotenv
24
+ from mcp import ClientSession, StdioServerParameters
25
+ from mcp.client.stdio import stdio_client
21
26
 
22
27
  from PIL import Image
23
28
  from PIL import ImageFile
@@ -62,14 +67,12 @@ from npcpy.memory.command_history import (
62
67
  save_conversation_message,
63
68
  generate_message_id,
64
69
  )
65
- from npcpy.npc_compiler import Jinx, NPC, Team
70
+ from npcpy.npc_compiler import Jinx, NPC, Team, load_jinxs_from_directory, build_jinx_tool_catalog
66
71
 
67
72
  from npcpy.llm_funcs import (
68
73
  get_llm_response, check_llm_command
69
74
  )
70
- from npcpy.npc_compiler import NPC
71
- import base64
72
-
75
+ from termcolor import cprint
73
76
  from npcpy.tools import auto_tools
74
77
 
75
78
  import json
@@ -86,6 +89,159 @@ cancellation_flags = {}
86
89
  cancellation_lock = threading.Lock()
87
90
 
88
91
 
92
+ # Minimal MCP client (inlined from npcsh corca to avoid corca import)
93
+ class MCPClientNPC:
94
+ def __init__(self, debug: bool = True):
95
+ self.debug = debug
96
+ self.session: Optional[ClientSession] = None
97
+ try:
98
+ self._loop = asyncio.get_event_loop()
99
+ if self._loop.is_closed():
100
+ self._loop = asyncio.new_event_loop()
101
+ asyncio.set_event_loop(self._loop)
102
+ except RuntimeError:
103
+ self._loop = asyncio.new_event_loop()
104
+ asyncio.set_event_loop(self._loop)
105
+ self._exit_stack = self._loop.run_until_complete(AsyncExitStack().__aenter__())
106
+ self.available_tools_llm: List[Dict[str, Any]] = []
107
+ self.tool_map: Dict[str, Callable] = {}
108
+ self.server_script_path: Optional[str] = None
109
+
110
+ def _log(self, message: str, color: str = "cyan") -> None:
111
+ if self.debug:
112
+ cprint(f"[MCP Client] {message}", color, file=sys.stderr)
113
+
114
+ async def _connect_async(self, server_script_path: str) -> None:
115
+ self._log(f"Attempting to connect to MCP server: {server_script_path}")
116
+ self.server_script_path = server_script_path
117
+ abs_path = os.path.abspath(server_script_path)
118
+ if not os.path.exists(abs_path):
119
+ raise FileNotFoundError(f"MCP server script not found: {abs_path}")
120
+
121
+ if abs_path.endswith('.py'):
122
+ cmd_parts = [sys.executable, abs_path]
123
+ elif os.access(abs_path, os.X_OK):
124
+ cmd_parts = [abs_path]
125
+ else:
126
+ raise ValueError(f"Unsupported MCP server script type or not executable: {abs_path}")
127
+
128
+ server_params = StdioServerParameters(
129
+ command=cmd_parts[0],
130
+ args=[abs_path],
131
+ env=os.environ.copy(),
132
+ cwd=os.path.dirname(abs_path) or "."
133
+ )
134
+ if self.session:
135
+ await self._exit_stack.aclose()
136
+
137
+ self._exit_stack = AsyncExitStack()
138
+
139
+ stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
140
+ self.session = await self._exit_stack.enter_async_context(ClientSession(*stdio_transport))
141
+ await self.session.initialize()
142
+
143
+ response = await self.session.list_tools()
144
+ self.available_tools_llm = []
145
+ self.tool_map = {}
146
+
147
+ if response.tools:
148
+ for mcp_tool in response.tools:
149
+ tool_def = {
150
+ "type": "function",
151
+ "function": {
152
+ "name": mcp_tool.name,
153
+ "description": mcp_tool.description or f"MCP tool: {mcp_tool.name}",
154
+ "parameters": getattr(mcp_tool, "inputSchema", {"type": "object", "properties": {}})
155
+ }
156
+ }
157
+ self.available_tools_llm.append(tool_def)
158
+
159
+ def make_tool_func(tool_name_closure):
160
+ async def tool_func(**kwargs):
161
+ if not self.session:
162
+ return {"error": "No MCP session"}
163
+ self._log(f"About to call MCP tool {tool_name_closure}")
164
+ try:
165
+ cleaned_kwargs = {k: (None if v == 'None' else v) for k, v in kwargs.items()}
166
+ result = await asyncio.wait_for(
167
+ self.session.call_tool(tool_name_closure, cleaned_kwargs),
168
+ timeout=30.0
169
+ )
170
+ self._log(f"MCP tool {tool_name_closure} returned: {type(result)}")
171
+ return result
172
+ except asyncio.TimeoutError:
173
+ self._log(f"Tool {tool_name_closure} timed out after 30 seconds", "red")
174
+ return {"error": f"Tool {tool_name_closure} timed out"}
175
+ except Exception as e:
176
+ self._log(f"Tool {tool_name_closure} error: {e}", "red")
177
+ return {"error": str(e)}
178
+
179
+ def sync_wrapper(**kwargs):
180
+ self._log(f"Sync wrapper called for {tool_name_closure}")
181
+ return self._loop.run_until_complete(tool_func(**kwargs))
182
+
183
+ return sync_wrapper
184
+
185
+ self.tool_map[mcp_tool.name] = make_tool_func(mcp_tool.name)
186
+ tool_names = list(self.tool_map.keys())
187
+ self._log(f"Connection successful. Tools: {', '.join(tool_names) if tool_names else 'None'}")
188
+
189
+ def connect_sync(self, server_script_path: str) -> bool:
190
+ loop = self._loop
191
+ if loop.is_closed():
192
+ self._loop = asyncio.new_event_loop()
193
+ asyncio.set_event_loop(self._loop)
194
+ loop = self._loop
195
+ try:
196
+ loop.run_until_complete(self._connect_async(server_script_path))
197
+ return True
198
+ except Exception as e:
199
+ cprint(f"MCP connection failed: {e}", "red", file=sys.stderr)
200
+ return False
201
+
202
+ def disconnect_sync(self):
203
+ if self.session:
204
+ self._log("Disconnecting MCP session.")
205
+ loop = self._loop
206
+ if not loop.is_closed():
207
+ try:
208
+ async def close_session():
209
+ await self.session.close()
210
+ await self._exit_stack.aclose()
211
+ loop.run_until_complete(close_session())
212
+ except RuntimeError:
213
+ pass
214
+ except Exception as e:
215
+ print(f"Error during MCP client disconnect: {e}", file=sys.stderr)
216
+ self.session = None
217
+ self._exit_stack = None
218
+
219
+
220
+ def get_llm_response_with_handling(prompt, npc, messages, tools, stream, team, context=None):
221
+ """Unified LLM response with basic exception handling (inlined from corca to avoid that dependency)."""
222
+ try:
223
+ return get_llm_response(
224
+ prompt=prompt,
225
+ npc=npc,
226
+ messages=messages,
227
+ tools=tools,
228
+ auto_process_tool_calls=False,
229
+ stream=stream,
230
+ team=team,
231
+ context=context
232
+ )
233
+ except Exception:
234
+ # Fallback retry without context compression logic to keep it simple here.
235
+ return get_llm_response(
236
+ prompt=prompt,
237
+ npc=npc,
238
+ messages=messages,
239
+ tools=tools,
240
+ auto_process_tool_calls=False,
241
+ stream=stream,
242
+ team=team,
243
+ context=context
244
+ )
89
245
  class MCPServerManager:
90
246
  """
91
247
  Simple in-process tracker for launching/stopping MCP servers.
@@ -2488,11 +2644,13 @@ def generate_images():
2488
2644
  if os.path.exists(image_path):
2489
2645
  try:
2490
2646
  pil_img = Image.open(image_path)
2647
+ pil_img = pil_img.convert("RGB")
2648
+ pil_img.thumbnail((1024, 1024))
2491
2649
  input_images.append(pil_img)
2492
2650
 
2493
-
2494
- with open(image_path, 'rb') as f:
2495
- img_data = f.read()
2651
+ compressed_bytes = BytesIO()
2652
+ pil_img.save(compressed_bytes, format="JPEG", quality=85, optimize=True)
2653
+ img_data = compressed_bytes.getvalue()
2496
2654
  attachments_loaded.append({
2497
2655
  "name": os.path.basename(image_path),
2498
2656
  "type": "images",
@@ -2620,6 +2778,7 @@ def get_mcp_tools():
2620
2778
  return jsonify({"error": "MCP Client (npcsh.corca) not available. Ensure npcsh.corca is installed and importable."}), 500
2621
2779
 
2622
2780
  temp_mcp_client = None
2781
+ jinx_tools = []
2623
2782
  try:
2624
2783
 
2625
2784
  if conversation_id and npc_name and hasattr(app, 'corca_states'):
@@ -2640,6 +2799,25 @@ def get_mcp_tools():
2640
2799
  temp_mcp_client = MCPClientNPC()
2641
2800
  if temp_mcp_client.connect_sync(server_path):
2642
2801
  tools = temp_mcp_client.available_tools_llm
2802
+ # Append Jinx-derived tools discovered from global/project jinxs
2803
+ try:
2804
+ jinx_dirs = []
2805
+ if current_path_arg:
2806
+ proj_jinx_dir = os.path.join(os.path.abspath(current_path_arg), "npc_team", "jinxs")
2807
+ if os.path.isdir(proj_jinx_dir):
2808
+ jinx_dirs.append(proj_jinx_dir)
2809
+ global_jinx_dir = os.path.expanduser("~/.npcsh/npc_team/jinxs")
2810
+ if os.path.isdir(global_jinx_dir):
2811
+ jinx_dirs.append(global_jinx_dir)
2812
+ all_jinxs = []
2813
+ for d in jinx_dirs:
2814
+ all_jinxs.extend(load_jinxs_from_directory(d))
2815
+ if all_jinxs:
2816
+ jinx_tools = list(build_jinx_tool_catalog({j.jinx_name: j for j in all_jinxs}).values())
2817
+ print(f"[MCP] Discovered {len(jinx_tools)} Jinx tools for listing.")
2818
+ tools = tools + jinx_tools
2819
+ except Exception as e:
2820
+ print(f"[MCP] Error discovering Jinx tools for listing: {e}")
2643
2821
  if selected_names:
2644
2822
  tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2645
2823
  return jsonify({"tools": tools, "error": None})
@@ -2944,6 +3122,8 @@ def stream():
2944
3122
 
2945
3123
  commandstr = data.get("commandstr")
2946
3124
  conversation_id = data.get("conversationId")
3125
+ if not conversation_id:
3126
+ return jsonify({"error": "conversationId is required"}), 400
2947
3127
  model = data.get("model", None)
2948
3128
  provider = data.get("provider", None)
2949
3129
  if provider is None:
@@ -2961,6 +3141,7 @@ def stream():
2961
3141
  npc_object = None
2962
3142
  team_object = None
2963
3143
  team = None
3144
+ tool_results_for_db = []
2964
3145
  if npc_name:
2965
3146
  if hasattr(app, 'registered_teams'):
2966
3147
  for team_name, team_object in app.registered_teams.items():
@@ -3199,83 +3380,257 @@ def stream():
3199
3380
  )
3200
3381
  messages = state.messages
3201
3382
 
3202
- elif exe_mode == 'corca':
3203
- try:
3204
- from npcsh.corca import execute_command_corca, create_corca_state_and_mcp_client, MCPClientNPC
3205
- from npcsh._state import initial_state as state
3206
- except ImportError:
3207
- print("ERROR: npcsh.corca or MCPClientNPC not found. Corca mode is disabled.", file=sys.stderr)
3208
- state = None
3209
- stream_response = {"output": "Corca mode is not available due to missing dependencies.", "messages": messages}
3210
-
3211
- if state is not None:
3212
- mcp_server_path_from_request = data.get("mcpServerPath")
3213
- selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
3214
-
3215
- effective_mcp_server_path = mcp_server_path_from_request
3216
- if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
3217
- mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
3218
- if mcp_servers_list and isinstance(mcp_servers_list, list):
3219
- first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
3220
- if first_server_obj:
3221
- effective_mcp_server_path = first_server_obj['value']
3222
- elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3223
- effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
3224
-
3225
- if effective_mcp_server_path:
3226
- effective_mcp_server_path = os.path.abspath(os.path.expanduser(effective_mcp_server_path))
3227
-
3228
- if not hasattr(app, 'corca_states'):
3229
- app.corca_states = {}
3230
-
3231
- state_key = f"{conversation_id}_{npc_name or 'default'}"
3232
- corca_state = app.corca_states.get(state_key)
3383
+ elif exe_mode == 'tool_agent':
3384
+ mcp_server_path_from_request = data.get("mcpServerPath")
3385
+ selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
3386
+
3387
+ # Resolve MCP server path (explicit -> team ctx -> default resolver)
3388
+ effective_mcp_server_path = mcp_server_path_from_request
3389
+ if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
3390
+ mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
3391
+ if mcp_servers_list and isinstance(mcp_servers_list, list):
3392
+ first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
3393
+ if first_server_obj:
3394
+ effective_mcp_server_path = first_server_obj['value']
3395
+ elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3396
+ effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
3397
+
3398
+ effective_mcp_server_path = resolve_mcp_server_path(
3399
+ current_path=current_path,
3400
+ explicit_path=effective_mcp_server_path,
3401
+ force_global=False
3402
+ )
3403
+ print(f"[MCP] effective server path: {effective_mcp_server_path}")
3404
+
3405
+ if not hasattr(app, 'mcp_clients'):
3406
+ app.mcp_clients = {}
3407
+
3408
+ state_key = f"{conversation_id}_{npc_name or 'default'}"
3409
+ client_entry = app.mcp_clients.get(state_key)
3410
+
3411
+ if not client_entry or not client_entry.get("client") or not client_entry["client"].session \
3412
+ or client_entry.get("server_path") != effective_mcp_server_path:
3413
+ mcp_client = MCPClientNPC()
3414
+ if effective_mcp_server_path and mcp_client.connect_sync(effective_mcp_server_path):
3415
+ print(f"[MCP] connected client for {state_key} to {effective_mcp_server_path}")
3416
+ app.mcp_clients[state_key] = {
3417
+ "client": mcp_client,
3418
+ "server_path": effective_mcp_server_path,
3419
+ "messages": messages
3420
+ }
3421
+ else:
3422
+ print(f"[MCP] Failed to connect client for {state_key} to {effective_mcp_server_path}")
3423
+ app.mcp_clients[state_key] = {
3424
+ "client": None,
3425
+ "server_path": effective_mcp_server_path,
3426
+ "messages": messages
3427
+ }
3233
3428
 
3234
- if corca_state is None:
3235
- corca_state = create_corca_state_and_mcp_client(
3236
- conversation_id=conversation_id,
3237
- command_history=command_history,
3429
+ mcp_client = app.mcp_clients[state_key]["client"]
3430
+ messages = app.mcp_clients[state_key].get("messages", messages)
3431
+
3432
+ def stream_mcp_sse():
3433
+ nonlocal messages
3434
+ iteration = 0
3435
+ prompt = commandstr
3436
+ while iteration < 10:
3437
+ iteration += 1
3438
+ print(f"[MCP] iteration {iteration} prompt len={len(prompt)}")
3439
+ jinx_tool_catalog = {}
3440
+ if npc_object and hasattr(npc_object, "jinx_tool_catalog"):
3441
+ jinx_tool_catalog = npc_object.jinx_tool_catalog or {}
3442
+ tools_for_llm = []
3443
+ if mcp_client:
3444
+ tools_for_llm.extend(mcp_client.available_tools_llm)
3445
+ # append Jinx-derived tools
3446
+ tools_for_llm.extend(list(jinx_tool_catalog.values()))
3447
+ if selected_mcp_tools_from_request:
3448
+ tools_for_llm = [t for t in tools_for_llm if t["function"]["name"] in selected_mcp_tools_from_request]
3449
+ print(f"[MCP] tools_for_llm: {[t['function']['name'] for t in tools_for_llm]}")
3450
+
3451
+ llm_response = get_llm_response_with_handling(
3452
+ prompt=prompt,
3238
3453
  npc=npc_object,
3454
+ messages=messages,
3455
+ tools=tools_for_llm,
3456
+ stream=True,
3239
3457
  team=team_object,
3240
- current_path=current_path,
3241
- mcp_server_path=effective_mcp_server_path
3458
+ context=f' The users working directory is {current_path}'
3242
3459
  )
3243
- app.corca_states[state_key] = corca_state
3244
- else:
3245
- corca_state.npc = npc_object
3246
- corca_state.team = team_object
3247
- corca_state.current_path = current_path
3248
- corca_state.messages = messages
3249
- corca_state.command_history = command_history
3250
-
3251
- current_mcp_client_path = getattr(corca_state.mcp_client, 'server_script_path', None)
3252
- if current_mcp_client_path:
3253
- current_mcp_client_path = os.path.abspath(os.path.expanduser(current_mcp_client_path))
3254
-
3255
- if effective_mcp_server_path != current_mcp_client_path:
3256
- print(f"MCP server path changed/updated for {state_key}. Disconnecting old client (if any) and reconnecting to {effective_mcp_server_path or 'None'}.")
3257
- if corca_state.mcp_client and corca_state.mcp_client.session:
3258
- corca_state.mcp_client.disconnect_sync()
3259
- corca_state.mcp_client = None
3260
-
3261
- if effective_mcp_server_path:
3262
- new_mcp_client = MCPClientNPC()
3263
- if new_mcp_client.connect_sync(effective_mcp_server_path):
3264
- corca_state.mcp_client = new_mcp_client
3265
- print(f"Successfully reconnected MCP client for {state_key} to {effective_mcp_server_path}.")
3460
+
3461
+ stream = llm_response.get("response", [])
3462
+ messages = llm_response.get("messages", messages)
3463
+ collected_content = ""
3464
+ collected_tool_calls = []
3465
+
3466
+ for response_chunk in stream:
3467
+ with cancellation_lock:
3468
+ if cancellation_flags.get(stream_id, False):
3469
+ yield {"type": "interrupt"}
3470
+ return
3471
+
3472
+ if hasattr(response_chunk, "choices") and response_chunk.choices:
3473
+ delta = response_chunk.choices[0].delta
3474
+ if hasattr(delta, "content") and delta.content:
3475
+ collected_content += delta.content
3476
+ chunk_data = {
3477
+ "id": getattr(response_chunk, "id", None),
3478
+ "object": getattr(response_chunk, "object", None),
3479
+ "created": getattr(response_chunk, "created", datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS')),
3480
+ "model": getattr(response_chunk, "model", model),
3481
+ "choices": [
3482
+ {
3483
+ "index": 0,
3484
+ "delta": {
3485
+ "content": delta.content,
3486
+ "role": "assistant"
3487
+ },
3488
+ "finish_reason": None
3489
+ }
3490
+ ]
3491
+ }
3492
+ yield chunk_data
3493
+
3494
+ if hasattr(delta, "tool_calls") and delta.tool_calls:
3495
+ for tool_call_delta in delta.tool_calls:
3496
+ idx = getattr(tool_call_delta, "index", 0)
3497
+ while len(collected_tool_calls) <= idx:
3498
+ collected_tool_calls.append({
3499
+ "id": "",
3500
+ "type": "function",
3501
+ "function": {"name": "", "arguments": ""}
3502
+ })
3503
+ if getattr(tool_call_delta, "id", None):
3504
+ collected_tool_calls[idx]["id"] = tool_call_delta.id
3505
+ if hasattr(tool_call_delta, "function"):
3506
+ fn = tool_call_delta.function
3507
+ if getattr(fn, "name", None):
3508
+ collected_tool_calls[idx]["function"]["name"] = fn.name
3509
+ if getattr(fn, "arguments", None):
3510
+ collected_tool_calls[idx]["function"]["arguments"] += fn.arguments
3511
+
3512
+ if not collected_tool_calls:
3513
+ print("[MCP] no tool calls, finishing streaming loop")
3514
+ break
3515
+
3516
+ print(f"[MCP] collected tool calls: {[tc['function']['name'] for tc in collected_tool_calls]}")
3517
+ yield {
3518
+ "type": "tool_execution_start",
3519
+ "tool_calls": [
3520
+ {
3521
+ "name": tc["function"]["name"],
3522
+ "id": tc["id"],
3523
+ "function": {
3524
+ "name": tc["function"]["name"],
3525
+ "arguments": tc["function"].get("arguments", "")
3526
+ }
3527
+ } for tc in collected_tool_calls
3528
+ ]
3529
+ }
3530
+
3531
+ tool_results = []
3532
+ for tc in collected_tool_calls:
3533
+ tool_name = tc["function"]["name"]
3534
+ tool_args = tc["function"]["arguments"]
3535
+ tool_id = tc["id"]
3536
+
3537
+ if isinstance(tool_args, str):
3538
+ try:
3539
+ tool_args = json.loads(tool_args) if tool_args.strip() else {}
3540
+ except json.JSONDecodeError:
3541
+ tool_args = {}
3542
+
3543
+ print(f"[MCP] tool_start {tool_name} args={tool_args}")
3544
+ yield {"type": "tool_start", "name": tool_name, "id": tool_id, "args": tool_args}
3545
+ try:
3546
+ tool_content = ""
3547
+ # First, try local Jinx execution
3548
+ if npc_object and hasattr(npc_object, "jinxs_dict") and tool_name in npc_object.jinxs_dict:
3549
+ jinx_obj = npc_object.jinxs_dict[tool_name]
3550
+ try:
3551
+ jinx_ctx = jinx_obj.execute(
3552
+ input_values=tool_args if isinstance(tool_args, dict) else {},
3553
+ npc=npc_object,
3554
+ messages=messages
3555
+ )
3556
+ tool_content = str(jinx_ctx.get("output", jinx_ctx))
3557
+ print(f"[MCP] jinx tool_complete {tool_name}")
3558
+ except Exception as e:
3559
+ raise Exception(f"Jinx execution failed: {e}")
3266
3560
  else:
3267
- print(f"Failed to reconnect MCP client for {state_key} to {effective_mcp_server_path}. Corca will have no tools.")
3268
- corca_state.mcp_client = None
3561
+ try:
3562
+ loop = asyncio.get_event_loop()
3563
+ except RuntimeError:
3564
+ loop = asyncio.new_event_loop()
3565
+ asyncio.set_event_loop(loop)
3566
+ if loop.is_closed():
3567
+ loop = asyncio.new_event_loop()
3568
+ asyncio.set_event_loop(loop)
3569
+ mcp_result = loop.run_until_complete(
3570
+ mcp_client.session.call_tool(tool_name, tool_args)
3571
+ ) if mcp_client else {"error": "No MCP client"}
3572
+ if hasattr(mcp_result, "content") and mcp_result.content:
3573
+ for content_item in mcp_result.content:
3574
+ if hasattr(content_item, "text"):
3575
+ tool_content += content_item.text
3576
+ elif hasattr(content_item, "data"):
3577
+ tool_content += str(content_item.data)
3578
+ else:
3579
+ tool_content += str(content_item)
3580
+ else:
3581
+ tool_content = str(mcp_result)
3582
+
3583
+ tool_results.append({
3584
+ "role": "tool",
3585
+ "tool_call_id": tool_id,
3586
+ "name": tool_name,
3587
+ "content": tool_content
3588
+ })
3589
+
3590
+ print(f"[MCP] tool_complete {tool_name}")
3591
+ yield {"type": "tool_complete", "name": tool_name, "id": tool_id, "result_preview": tool_content[:4000]}
3592
+ except Exception as e:
3593
+ err_msg = f"Error executing {tool_name}: {e}"
3594
+ tool_results.append({
3595
+ "role": "tool",
3596
+ "tool_call_id": tool_id,
3597
+ "name": tool_name,
3598
+ "content": err_msg
3599
+ })
3600
+ print(f"[MCP] tool_error {tool_name}: {e}")
3601
+ yield {"type": "tool_error", "name": tool_name, "id": tool_id, "error": str(e)}
3602
+
3603
+ serialized_tool_calls = []
3604
+ for tc in collected_tool_calls:
3605
+ parsed_args = tc["function"]["arguments"]
3606
+ # Gemini/LLM expects arguments as JSON string, not dict
3607
+ if isinstance(parsed_args, dict):
3608
+ args_for_message = json.dumps(parsed_args)
3609
+ else:
3610
+ args_for_message = str(parsed_args)
3611
+ serialized_tool_calls.append({
3612
+ "id": tc["id"],
3613
+ "type": tc["type"],
3614
+ "function": {
3615
+ "name": tc["function"]["name"],
3616
+ "arguments": args_for_message
3617
+ }
3618
+ })
3269
3619
 
3270
- state, stream_response = execute_command_corca(
3271
- commandstr,
3272
- corca_state,
3273
- command_history,
3274
- selected_mcp_tools_names=selected_mcp_tools_from_request
3275
- )
3620
+ messages.append({
3621
+ "role": "assistant",
3622
+ "content": collected_content,
3623
+ "tool_calls": serialized_tool_calls
3624
+ })
3625
+ messages.extend(tool_results)
3626
+ tool_results_for_db = tool_results
3627
+
3628
+ prompt = ""
3276
3629
 
3277
- app.corca_states[state_key] = state
3278
- messages = state.messages
3630
+ app.mcp_clients[state_key]["messages"] = messages
3631
+ return
3632
+
3633
+ stream_response = stream_mcp_sse()
3279
3634
 
3280
3635
  else:
3281
3636
  stream_response = {"output": f"Unsupported execution mode: {exe_mode}", "messages": messages}
@@ -3316,6 +3671,36 @@ def stream():
3316
3671
  tool_call_data = {"id": None, "function_name": None, "arguments": ""}
3317
3672
 
3318
3673
  try:
3674
+ # New: handle generators (tool_agent streaming)
3675
+ if hasattr(stream_response, "__iter__") and not isinstance(stream_response, (dict, str)):
3676
+ for chunk in stream_response:
3677
+ with cancellation_lock:
3678
+ if cancellation_flags.get(current_stream_id, False):
3679
+ interrupted = True
3680
+ break
3681
+ if chunk is None:
3682
+ continue
3683
+ if isinstance(chunk, dict):
3684
+ if chunk.get("type") == "interrupt":
3685
+ interrupted = True
3686
+ break
3687
+ yield f"data: {json.dumps(chunk)}\n\n"
3688
+ if chunk.get("choices"):
3689
+ for choice in chunk["choices"]:
3690
+ delta = choice.get("delta", {})
3691
+ content_piece = delta.get("content")
3692
+ if content_piece:
3693
+ complete_response.append(content_piece)
3694
+ continue
3695
+ yield f"data: {json.dumps({'choices':[{'delta':{'content': str(chunk), 'role': 'assistant'},'finish_reason':None}]})}\n\n"
3696
+ # ensure stream termination and cleanup for generator flows
3697
+ yield "data: [DONE]\n\n"
3698
+ with cancellation_lock:
3699
+ if current_stream_id in cancellation_flags:
3700
+ del cancellation_flags[current_stream_id]
3701
+ print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
3702
+ return
3703
+
3319
3704
  if isinstance(stream_response, str) :
3320
3705
  print('stream a str and not a gen')
3321
3706
  chunk_data = {
@@ -3429,6 +3814,36 @@ def stream():
3429
3814
  # Yield message_stop immediately so the client's stream ends quickly
3430
3815
  yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
3431
3816
 
3817
+ # Persist tool call metadata and results before final assistant content
3818
+ if tool_call_data.get("function_name") or tool_call_data.get("arguments"):
3819
+ save_conversation_message(
3820
+ command_history,
3821
+ conversation_id,
3822
+ "assistant",
3823
+ {"tool_call": tool_call_data},
3824
+ wd=current_path,
3825
+ model=model,
3826
+ provider=provider,
3827
+ npc=npc_name,
3828
+ team=team,
3829
+ message_id=generate_message_id(),
3830
+ )
3831
+
3832
+ if tool_results_for_db:
3833
+ for tr in tool_results_for_db:
3834
+ save_conversation_message(
3835
+ command_history,
3836
+ conversation_id,
3837
+ "tool",
3838
+ {"tool_name": tr.get("name"), "tool_call_id": tr.get("tool_call_id"), "content": tr.get("content")},
3839
+ wd=current_path,
3840
+ model=model,
3841
+ provider=provider,
3842
+ npc=npc_name,
3843
+ team=team,
3844
+ message_id=generate_message_id(),
3845
+ )
3846
+
3432
3847
  # Save assistant message to the database
3433
3848
  npc_name_to_save = npc_object.name if npc_object else ''
3434
3849
  save_conversation_message(
@@ -3682,6 +4097,37 @@ def ollama_status():
3682
4097
  return jsonify({"status": "not_found"})
3683
4098
 
3684
4099
 
4100
+ @app.route("/api/ollama/tool_models", methods=["GET"])
4101
+ def get_ollama_tool_models():
4102
+ """
4103
+ Best-effort detection of Ollama models whose templates include tool-call support.
4104
+ We scan templates for tool placeholders; if none are found we assume tools are unsupported.
4105
+ """
4106
+ try:
4107
+ detected = []
4108
+ listing = ollama.list()
4109
+ for model in listing.get("models", []):
4110
+ name = getattr(model, "model", None) or model.get("name") if isinstance(model, dict) else None
4111
+ if not name:
4112
+ continue
4113
+ try:
4114
+ details = ollama.show(name)
4115
+ tmpl = details.get("template") or ""
4116
+ if "{{- if .Tools" in tmpl or "{{- range .Tools" in tmpl or "{{- if .ToolCalls" in tmpl:
4117
+ detected.append(name)
4118
+ continue
4119
+ metadata = details.get("metadata") or {}
4120
+ if metadata.get("tools") or metadata.get("tool_calls"):
4121
+ detected.append(name)
4122
+ except Exception as inner_e:
4123
+ print(f"Warning: could not inspect ollama model {name} for tool support: {inner_e}")
4124
+ continue
4125
+ return jsonify({"models": detected, "error": None})
4126
+ except Exception as e:
4127
+ print(f"Error listing Ollama tool-capable models: {e}")
4128
+ return jsonify({"models": [], "error": str(e)}), 500
4129
+
4130
+
3685
4131
  @app.route('/api/ollama/models', methods=['GET'])
3686
4132
  def get_ollama_models():
3687
4133
  response = ollama.list()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.2.35
3
+ Version: 1.2.36
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -83,7 +83,7 @@ extra_files = package_files("npcpy/npc_team/")
83
83
 
84
84
  setup(
85
85
  name="npcpy",
86
- version="1.2.35",
86
+ version="1.2.36",
87
87
  packages=find_packages(exclude=["tests*"]),
88
88
  install_requires=base_requirements,
89
89
  extras_require={
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes