npcpy 1.2.35__py3-none-any.whl → 1.2.37__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.
npcpy/serve.py CHANGED
@@ -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
@@ -45,7 +50,6 @@ from npcpy.memory.search import execute_rag_command, execute_brainblast_command
45
50
  from npcpy.data.load import load_file_contents
46
51
  from npcpy.data.web import search_web
47
52
 
48
- from npcsh._state import get_relevant_memories, search_kg_facts
49
53
 
50
54
  import base64
51
55
  import shutil
@@ -62,14 +66,12 @@ from npcpy.memory.command_history import (
62
66
  save_conversation_message,
63
67
  generate_message_id,
64
68
  )
65
- from npcpy.npc_compiler import Jinx, NPC, Team
69
+ from npcpy.npc_compiler import Jinx, NPC, Team, load_jinxs_from_directory, build_jinx_tool_catalog, initialize_npc_project
66
70
 
67
71
  from npcpy.llm_funcs import (
68
72
  get_llm_response, check_llm_command
69
73
  )
70
- from npcpy.npc_compiler import NPC
71
- import base64
72
-
74
+ from termcolor import cprint
73
75
  from npcpy.tools import auto_tools
74
76
 
75
77
  import json
@@ -86,6 +88,159 @@ cancellation_flags = {}
86
88
  cancellation_lock = threading.Lock()
87
89
 
88
90
 
91
+ # Minimal MCP client (inlined from npcsh corca to avoid corca import)
92
+ class MCPClientNPC:
93
+ def __init__(self, debug: bool = True):
94
+ self.debug = debug
95
+ self.session: Optional[ClientSession] = None
96
+ try:
97
+ self._loop = asyncio.get_event_loop()
98
+ if self._loop.is_closed():
99
+ self._loop = asyncio.new_event_loop()
100
+ asyncio.set_event_loop(self._loop)
101
+ except RuntimeError:
102
+ self._loop = asyncio.new_event_loop()
103
+ asyncio.set_event_loop(self._loop)
104
+ self._exit_stack = self._loop.run_until_complete(AsyncExitStack().__aenter__())
105
+ self.available_tools_llm: List[Dict[str, Any]] = []
106
+ self.tool_map: Dict[str, Callable] = {}
107
+ self.server_script_path: Optional[str] = None
108
+
109
+ def _log(self, message: str, color: str = "cyan") -> None:
110
+ if self.debug:
111
+ cprint(f"[MCP Client] {message}", color, file=sys.stderr)
112
+
113
+ async def _connect_async(self, server_script_path: str) -> None:
114
+ self._log(f"Attempting to connect to MCP server: {server_script_path}")
115
+ self.server_script_path = server_script_path
116
+ abs_path = os.path.abspath(server_script_path)
117
+ if not os.path.exists(abs_path):
118
+ raise FileNotFoundError(f"MCP server script not found: {abs_path}")
119
+
120
+ if abs_path.endswith('.py'):
121
+ cmd_parts = [sys.executable, abs_path]
122
+ elif os.access(abs_path, os.X_OK):
123
+ cmd_parts = [abs_path]
124
+ else:
125
+ raise ValueError(f"Unsupported MCP server script type or not executable: {abs_path}")
126
+
127
+ server_params = StdioServerParameters(
128
+ command=cmd_parts[0],
129
+ args=[abs_path],
130
+ env=os.environ.copy(),
131
+ cwd=os.path.dirname(abs_path) or "."
132
+ )
133
+ if self.session:
134
+ await self._exit_stack.aclose()
135
+
136
+ self._exit_stack = AsyncExitStack()
137
+
138
+ stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
139
+ self.session = await self._exit_stack.enter_async_context(ClientSession(*stdio_transport))
140
+ await self.session.initialize()
141
+
142
+ response = await self.session.list_tools()
143
+ self.available_tools_llm = []
144
+ self.tool_map = {}
145
+
146
+ if response.tools:
147
+ for mcp_tool in response.tools:
148
+ tool_def = {
149
+ "type": "function",
150
+ "function": {
151
+ "name": mcp_tool.name,
152
+ "description": mcp_tool.description or f"MCP tool: {mcp_tool.name}",
153
+ "parameters": getattr(mcp_tool, "inputSchema", {"type": "object", "properties": {}})
154
+ }
155
+ }
156
+ self.available_tools_llm.append(tool_def)
157
+
158
+ def make_tool_func(tool_name_closure):
159
+ async def tool_func(**kwargs):
160
+ if not self.session:
161
+ return {"error": "No MCP session"}
162
+ self._log(f"About to call MCP tool {tool_name_closure}")
163
+ try:
164
+ cleaned_kwargs = {k: (None if v == 'None' else v) for k, v in kwargs.items()}
165
+ result = await asyncio.wait_for(
166
+ self.session.call_tool(tool_name_closure, cleaned_kwargs),
167
+ timeout=30.0
168
+ )
169
+ self._log(f"MCP tool {tool_name_closure} returned: {type(result)}")
170
+ return result
171
+ except asyncio.TimeoutError:
172
+ self._log(f"Tool {tool_name_closure} timed out after 30 seconds", "red")
173
+ return {"error": f"Tool {tool_name_closure} timed out"}
174
+ except Exception as e:
175
+ self._log(f"Tool {tool_name_closure} error: {e}", "red")
176
+ return {"error": str(e)}
177
+
178
+ def sync_wrapper(**kwargs):
179
+ self._log(f"Sync wrapper called for {tool_name_closure}")
180
+ return self._loop.run_until_complete(tool_func(**kwargs))
181
+
182
+ return sync_wrapper
183
+
184
+ self.tool_map[mcp_tool.name] = make_tool_func(mcp_tool.name)
185
+ tool_names = list(self.tool_map.keys())
186
+ self._log(f"Connection successful. Tools: {', '.join(tool_names) if tool_names else 'None'}")
187
+
188
+ def connect_sync(self, server_script_path: str) -> bool:
189
+ loop = self._loop
190
+ if loop.is_closed():
191
+ self._loop = asyncio.new_event_loop()
192
+ asyncio.set_event_loop(self._loop)
193
+ loop = self._loop
194
+ try:
195
+ loop.run_until_complete(self._connect_async(server_script_path))
196
+ return True
197
+ except Exception as e:
198
+ cprint(f"MCP connection failed: {e}", "red", file=sys.stderr)
199
+ return False
200
+
201
+ def disconnect_sync(self):
202
+ if self.session:
203
+ self._log("Disconnecting MCP session.")
204
+ loop = self._loop
205
+ if not loop.is_closed():
206
+ try:
207
+ async def close_session():
208
+ await self.session.close()
209
+ await self._exit_stack.aclose()
210
+ loop.run_until_complete(close_session())
211
+ except RuntimeError:
212
+ pass
213
+ except Exception as e:
214
+ print(f"Error during MCP client disconnect: {e}", file=sys.stderr)
215
+ self.session = None
216
+ self._exit_stack = None
217
+
218
+
219
+ def get_llm_response_with_handling(prompt, npc, messages, tools, stream, team, context=None):
220
+ """Unified LLM response with basic exception handling (inlined from corca to avoid that dependency)."""
221
+ try:
222
+ return get_llm_response(
223
+ prompt=prompt,
224
+ npc=npc,
225
+ messages=messages,
226
+ tools=tools,
227
+ auto_process_tool_calls=False,
228
+ stream=stream,
229
+ team=team,
230
+ context=context
231
+ )
232
+ except Exception:
233
+ # Fallback retry without context compression logic to keep it simple here.
234
+ return get_llm_response(
235
+ prompt=prompt,
236
+ npc=npc,
237
+ messages=messages,
238
+ tools=tools,
239
+ auto_process_tool_calls=False,
240
+ stream=stream,
241
+ team=team,
242
+ context=context
243
+ )
89
244
  class MCPServerManager:
90
245
  """
91
246
  Simple in-process tracker for launching/stopping MCP servers.
@@ -816,12 +971,8 @@ def execute_jinx():
816
971
  'state': state,
817
972
  'CommandHistory': CommandHistory,
818
973
  'load_kg_from_db': load_kg_from_db,
819
- 'execute_rag_command': execute_rag_command,
820
- 'execute_brainblast_command': execute_brainblast_command,
821
- 'load_file_contents': load_file_contents,
822
- 'search_web': search_web,
823
- 'get_relevant_memories': get_relevant_memories,
824
- 'search_kg_facts': search_kg_facts,
974
+ #'get_relevant_memories': get_relevant_memories,
975
+ #'search_kg_facts': search_kg_facts,
825
976
  }
826
977
 
827
978
  jinx_execution_result = jinx.execute(
@@ -1765,6 +1916,134 @@ def get_jinxs_project():
1765
1916
  print(jinx_data)
1766
1917
  return jsonify({"jinxs": jinx_data, "error": None})
1767
1918
 
1919
+ # ============== SQL Models (npcsql) API Endpoints ==============
1920
+ @app.route("/api/npcsql/run_model", methods=["POST"])
1921
+ def run_npcsql_model():
1922
+ """Execute a single SQL model using ModelCompiler"""
1923
+ try:
1924
+ from npcpy.sql.npcsql import ModelCompiler
1925
+
1926
+ data = request.json
1927
+ models_dir = data.get("modelsDir")
1928
+ model_name = data.get("modelName")
1929
+ npc_directory = data.get("npcDirectory", os.path.expanduser("~/.npcsh/npc_team"))
1930
+ target_db = data.get("targetDb", os.path.expanduser("~/npcsh_history.db"))
1931
+
1932
+ if not models_dir or not model_name:
1933
+ return jsonify({"success": False, "error": "modelsDir and modelName are required"}), 400
1934
+
1935
+ if not os.path.exists(models_dir):
1936
+ return jsonify({"success": False, "error": f"Models directory not found: {models_dir}"}), 404
1937
+
1938
+ compiler = ModelCompiler(
1939
+ models_dir=models_dir,
1940
+ target_engine=target_db,
1941
+ npc_directory=npc_directory
1942
+ )
1943
+
1944
+ compiler.discover_models()
1945
+
1946
+ if model_name not in compiler.models:
1947
+ available = list(compiler.models.keys())
1948
+ return jsonify({
1949
+ "success": False,
1950
+ "error": f"Model '{model_name}' not found. Available: {available}"
1951
+ }), 404
1952
+
1953
+ result_df = compiler.execute_model(model_name)
1954
+ row_count = len(result_df) if result_df is not None else 0
1955
+
1956
+ return jsonify({
1957
+ "success": True,
1958
+ "rows": row_count,
1959
+ "message": f"Model '{model_name}' executed successfully. {row_count} rows materialized."
1960
+ })
1961
+
1962
+ except Exception as e:
1963
+ import traceback
1964
+ traceback.print_exc()
1965
+ return jsonify({"success": False, "error": str(e)}), 500
1966
+
1967
+ @app.route("/api/npcsql/run_all", methods=["POST"])
1968
+ def run_all_npcsql_models():
1969
+ """Execute all SQL models in dependency order using ModelCompiler"""
1970
+ try:
1971
+ from npcpy.sql.npcsql import ModelCompiler
1972
+
1973
+ data = request.json
1974
+ models_dir = data.get("modelsDir")
1975
+ npc_directory = data.get("npcDirectory", os.path.expanduser("~/.npcsh/npc_team"))
1976
+ target_db = data.get("targetDb", os.path.expanduser("~/npcsh_history.db"))
1977
+
1978
+ if not models_dir:
1979
+ return jsonify({"success": False, "error": "modelsDir is required"}), 400
1980
+
1981
+ if not os.path.exists(models_dir):
1982
+ return jsonify({"success": False, "error": f"Models directory not found: {models_dir}"}), 404
1983
+
1984
+ compiler = ModelCompiler(
1985
+ models_dir=models_dir,
1986
+ target_engine=target_db,
1987
+ npc_directory=npc_directory
1988
+ )
1989
+
1990
+ results = compiler.run_all_models()
1991
+
1992
+ summary = {
1993
+ name: len(df) if df is not None else 0
1994
+ for name, df in results.items()
1995
+ }
1996
+
1997
+ return jsonify({
1998
+ "success": True,
1999
+ "models_executed": list(results.keys()),
2000
+ "row_counts": summary,
2001
+ "message": f"Executed {len(results)} models successfully."
2002
+ })
2003
+
2004
+ except Exception as e:
2005
+ import traceback
2006
+ traceback.print_exc()
2007
+ return jsonify({"success": False, "error": str(e)}), 500
2008
+
2009
+ @app.route("/api/npcsql/models", methods=["GET"])
2010
+ def list_npcsql_models():
2011
+ """List available SQL models in a directory"""
2012
+ try:
2013
+ from npcpy.sql.npcsql import ModelCompiler
2014
+
2015
+ models_dir = request.args.get("modelsDir")
2016
+ if not models_dir:
2017
+ return jsonify({"success": False, "error": "modelsDir query param required"}), 400
2018
+
2019
+ if not os.path.exists(models_dir):
2020
+ return jsonify({"models": [], "error": None})
2021
+
2022
+ compiler = ModelCompiler(
2023
+ models_dir=models_dir,
2024
+ target_engine=os.path.expanduser("~/npcsh_history.db"),
2025
+ npc_directory=os.path.expanduser("~/.npcsh/npc_team")
2026
+ )
2027
+
2028
+ compiler.discover_models()
2029
+
2030
+ models_info = []
2031
+ for name, model in compiler.models.items():
2032
+ models_info.append({
2033
+ "name": name,
2034
+ "path": model.path,
2035
+ "has_ai_function": model.has_ai_function,
2036
+ "dependencies": list(model.dependencies),
2037
+ "config": model.config
2038
+ })
2039
+
2040
+ return jsonify({"models": models_info, "error": None})
2041
+
2042
+ except Exception as e:
2043
+ import traceback
2044
+ traceback.print_exc()
2045
+ return jsonify({"models": [], "error": str(e)}), 500
2046
+
1768
2047
  @app.route("/api/npc_team_global")
1769
2048
  def get_npc_team_global():
1770
2049
  global_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
@@ -1894,19 +2173,27 @@ def api_get_last_used_in_conversation():
1894
2173
  result = get_last_used_model_and_npc_in_conversation(conversation_id)
1895
2174
  return jsonify(result)
1896
2175
 
1897
- def get_ctx_path(is_global, current_path=None):
2176
+ def get_ctx_path(is_global, current_path=None, create_default=False):
1898
2177
  """Determines the path to the .ctx file."""
1899
2178
  if is_global:
1900
2179
  ctx_dir = os.path.join(os.path.expanduser("~/.npcsh/npc_team/"))
1901
2180
  ctx_files = glob.glob(os.path.join(ctx_dir, "*.ctx"))
1902
- return ctx_files[0] if ctx_files else None
2181
+ if ctx_files:
2182
+ return ctx_files[0]
2183
+ elif create_default:
2184
+ return os.path.join(ctx_dir, "team.ctx")
2185
+ return None
1903
2186
  else:
1904
2187
  if not current_path:
1905
2188
  return None
1906
-
2189
+
1907
2190
  ctx_dir = os.path.join(current_path, "npc_team")
1908
2191
  ctx_files = glob.glob(os.path.join(ctx_dir, "*.ctx"))
1909
- return ctx_files[0] if ctx_files else None
2192
+ if ctx_files:
2193
+ return ctx_files[0]
2194
+ elif create_default:
2195
+ return os.path.join(ctx_dir, "team.ctx")
2196
+ return None
1910
2197
 
1911
2198
 
1912
2199
  def read_ctx_file(file_path):
@@ -2007,10 +2294,10 @@ def save_project_context():
2007
2294
  data = request.json
2008
2295
  current_path = data.get("path")
2009
2296
  context_data = data.get("context", {})
2010
-
2297
+
2011
2298
  if not current_path:
2012
2299
  return jsonify({"error": "Project path is required."}), 400
2013
-
2300
+
2014
2301
  ctx_path = get_ctx_path(is_global=False, current_path=current_path)
2015
2302
  if write_ctx_file(ctx_path, context_data):
2016
2303
  return jsonify({"message": "Project context saved.", "error": None})
@@ -2020,6 +2307,23 @@ def save_project_context():
2020
2307
  print(f"Error saving project context: {e}")
2021
2308
  return jsonify({"error": str(e)}), 500
2022
2309
 
2310
+ @app.route("/api/context/project/init", methods=["POST"])
2311
+ def init_project_team():
2312
+ """Initialize a new npc_team folder in the project directory."""
2313
+ try:
2314
+ data = request.json
2315
+ project_path = data.get("path")
2316
+
2317
+ if not project_path:
2318
+ return jsonify({"error": "Project path is required."}), 400
2319
+
2320
+ # Use the existing initialize_npc_project function
2321
+ result = initialize_npc_project(directory=project_path)
2322
+ return jsonify({"message": "Project team initialized.", "path": result, "error": None})
2323
+ except Exception as e:
2324
+ print(f"Error initializing project team: {e}")
2325
+ return jsonify({"error": str(e)}), 500
2326
+
2023
2327
 
2024
2328
 
2025
2329
 
@@ -2488,11 +2792,13 @@ def generate_images():
2488
2792
  if os.path.exists(image_path):
2489
2793
  try:
2490
2794
  pil_img = Image.open(image_path)
2795
+ pil_img = pil_img.convert("RGB")
2796
+ pil_img.thumbnail((1024, 1024))
2491
2797
  input_images.append(pil_img)
2492
2798
 
2493
-
2494
- with open(image_path, 'rb') as f:
2495
- img_data = f.read()
2799
+ compressed_bytes = BytesIO()
2800
+ pil_img.save(compressed_bytes, format="JPEG", quality=85, optimize=True)
2801
+ img_data = compressed_bytes.getvalue()
2496
2802
  attachments_loaded.append({
2497
2803
  "name": os.path.basename(image_path),
2498
2804
  "type": "images",
@@ -2620,6 +2926,7 @@ def get_mcp_tools():
2620
2926
  return jsonify({"error": "MCP Client (npcsh.corca) not available. Ensure npcsh.corca is installed and importable."}), 500
2621
2927
 
2622
2928
  temp_mcp_client = None
2929
+ jinx_tools = []
2623
2930
  try:
2624
2931
 
2625
2932
  if conversation_id and npc_name and hasattr(app, 'corca_states'):
@@ -2640,6 +2947,25 @@ def get_mcp_tools():
2640
2947
  temp_mcp_client = MCPClientNPC()
2641
2948
  if temp_mcp_client.connect_sync(server_path):
2642
2949
  tools = temp_mcp_client.available_tools_llm
2950
+ # Append Jinx-derived tools discovered from global/project jinxs
2951
+ try:
2952
+ jinx_dirs = []
2953
+ if current_path_arg:
2954
+ proj_jinx_dir = os.path.join(os.path.abspath(current_path_arg), "npc_team", "jinxs")
2955
+ if os.path.isdir(proj_jinx_dir):
2956
+ jinx_dirs.append(proj_jinx_dir)
2957
+ global_jinx_dir = os.path.expanduser("~/.npcsh/npc_team/jinxs")
2958
+ if os.path.isdir(global_jinx_dir):
2959
+ jinx_dirs.append(global_jinx_dir)
2960
+ all_jinxs = []
2961
+ for d in jinx_dirs:
2962
+ all_jinxs.extend(load_jinxs_from_directory(d))
2963
+ if all_jinxs:
2964
+ jinx_tools = list(build_jinx_tool_catalog({j.jinx_name: j for j in all_jinxs}).values())
2965
+ print(f"[MCP] Discovered {len(jinx_tools)} Jinx tools for listing.")
2966
+ tools = tools + jinx_tools
2967
+ except Exception as e:
2968
+ print(f"[MCP] Error discovering Jinx tools for listing: {e}")
2643
2969
  if selected_names:
2644
2970
  tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2645
2971
  return jsonify({"tools": tools, "error": None})
@@ -2944,6 +3270,8 @@ def stream():
2944
3270
 
2945
3271
  commandstr = data.get("commandstr")
2946
3272
  conversation_id = data.get("conversationId")
3273
+ if not conversation_id:
3274
+ return jsonify({"error": "conversationId is required"}), 400
2947
3275
  model = data.get("model", None)
2948
3276
  provider = data.get("provider", None)
2949
3277
  if provider is None:
@@ -2961,6 +3289,7 @@ def stream():
2961
3289
  npc_object = None
2962
3290
  team_object = None
2963
3291
  team = None
3292
+ tool_results_for_db = []
2964
3293
  if npc_name:
2965
3294
  if hasattr(app, 'registered_teams'):
2966
3295
  for team_name, team_object in app.registered_teams.items():
@@ -3199,83 +3528,257 @@ def stream():
3199
3528
  )
3200
3529
  messages = state.messages
3201
3530
 
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)
3531
+ elif exe_mode == 'tool_agent':
3532
+ mcp_server_path_from_request = data.get("mcpServerPath")
3533
+ selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
3534
+
3535
+ # Resolve MCP server path (explicit -> team ctx -> default resolver)
3536
+ effective_mcp_server_path = mcp_server_path_from_request
3537
+ if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
3538
+ mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
3539
+ if mcp_servers_list and isinstance(mcp_servers_list, list):
3540
+ first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
3541
+ if first_server_obj:
3542
+ effective_mcp_server_path = first_server_obj['value']
3543
+ elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3544
+ effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
3545
+
3546
+ effective_mcp_server_path = resolve_mcp_server_path(
3547
+ current_path=current_path,
3548
+ explicit_path=effective_mcp_server_path,
3549
+ force_global=False
3550
+ )
3551
+ print(f"[MCP] effective server path: {effective_mcp_server_path}")
3552
+
3553
+ if not hasattr(app, 'mcp_clients'):
3554
+ app.mcp_clients = {}
3555
+
3556
+ state_key = f"{conversation_id}_{npc_name or 'default'}"
3557
+ client_entry = app.mcp_clients.get(state_key)
3558
+
3559
+ if not client_entry or not client_entry.get("client") or not client_entry["client"].session \
3560
+ or client_entry.get("server_path") != effective_mcp_server_path:
3561
+ mcp_client = MCPClientNPC()
3562
+ if effective_mcp_server_path and mcp_client.connect_sync(effective_mcp_server_path):
3563
+ print(f"[MCP] connected client for {state_key} to {effective_mcp_server_path}")
3564
+ app.mcp_clients[state_key] = {
3565
+ "client": mcp_client,
3566
+ "server_path": effective_mcp_server_path,
3567
+ "messages": messages
3568
+ }
3569
+ else:
3570
+ print(f"[MCP] Failed to connect client for {state_key} to {effective_mcp_server_path}")
3571
+ app.mcp_clients[state_key] = {
3572
+ "client": None,
3573
+ "server_path": effective_mcp_server_path,
3574
+ "messages": messages
3575
+ }
3233
3576
 
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,
3577
+ mcp_client = app.mcp_clients[state_key]["client"]
3578
+ messages = app.mcp_clients[state_key].get("messages", messages)
3579
+
3580
+ def stream_mcp_sse():
3581
+ nonlocal messages
3582
+ iteration = 0
3583
+ prompt = commandstr
3584
+ while iteration < 10:
3585
+ iteration += 1
3586
+ print(f"[MCP] iteration {iteration} prompt len={len(prompt)}")
3587
+ jinx_tool_catalog = {}
3588
+ if npc_object and hasattr(npc_object, "jinx_tool_catalog"):
3589
+ jinx_tool_catalog = npc_object.jinx_tool_catalog or {}
3590
+ tools_for_llm = []
3591
+ if mcp_client:
3592
+ tools_for_llm.extend(mcp_client.available_tools_llm)
3593
+ # append Jinx-derived tools
3594
+ tools_for_llm.extend(list(jinx_tool_catalog.values()))
3595
+ if selected_mcp_tools_from_request:
3596
+ tools_for_llm = [t for t in tools_for_llm if t["function"]["name"] in selected_mcp_tools_from_request]
3597
+ print(f"[MCP] tools_for_llm: {[t['function']['name'] for t in tools_for_llm]}")
3598
+
3599
+ llm_response = get_llm_response_with_handling(
3600
+ prompt=prompt,
3238
3601
  npc=npc_object,
3602
+ messages=messages,
3603
+ tools=tools_for_llm,
3604
+ stream=True,
3239
3605
  team=team_object,
3240
- current_path=current_path,
3241
- mcp_server_path=effective_mcp_server_path
3606
+ context=f' The users working directory is {current_path}'
3242
3607
  )
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}.")
3608
+
3609
+ stream = llm_response.get("response", [])
3610
+ messages = llm_response.get("messages", messages)
3611
+ collected_content = ""
3612
+ collected_tool_calls = []
3613
+
3614
+ for response_chunk in stream:
3615
+ with cancellation_lock:
3616
+ if cancellation_flags.get(stream_id, False):
3617
+ yield {"type": "interrupt"}
3618
+ return
3619
+
3620
+ if hasattr(response_chunk, "choices") and response_chunk.choices:
3621
+ delta = response_chunk.choices[0].delta
3622
+ if hasattr(delta, "content") and delta.content:
3623
+ collected_content += delta.content
3624
+ chunk_data = {
3625
+ "id": getattr(response_chunk, "id", None),
3626
+ "object": getattr(response_chunk, "object", None),
3627
+ "created": getattr(response_chunk, "created", datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS')),
3628
+ "model": getattr(response_chunk, "model", model),
3629
+ "choices": [
3630
+ {
3631
+ "index": 0,
3632
+ "delta": {
3633
+ "content": delta.content,
3634
+ "role": "assistant"
3635
+ },
3636
+ "finish_reason": None
3637
+ }
3638
+ ]
3639
+ }
3640
+ yield chunk_data
3641
+
3642
+ if hasattr(delta, "tool_calls") and delta.tool_calls:
3643
+ for tool_call_delta in delta.tool_calls:
3644
+ idx = getattr(tool_call_delta, "index", 0)
3645
+ while len(collected_tool_calls) <= idx:
3646
+ collected_tool_calls.append({
3647
+ "id": "",
3648
+ "type": "function",
3649
+ "function": {"name": "", "arguments": ""}
3650
+ })
3651
+ if getattr(tool_call_delta, "id", None):
3652
+ collected_tool_calls[idx]["id"] = tool_call_delta.id
3653
+ if hasattr(tool_call_delta, "function"):
3654
+ fn = tool_call_delta.function
3655
+ if getattr(fn, "name", None):
3656
+ collected_tool_calls[idx]["function"]["name"] = fn.name
3657
+ if getattr(fn, "arguments", None):
3658
+ collected_tool_calls[idx]["function"]["arguments"] += fn.arguments
3659
+
3660
+ if not collected_tool_calls:
3661
+ print("[MCP] no tool calls, finishing streaming loop")
3662
+ break
3663
+
3664
+ print(f"[MCP] collected tool calls: {[tc['function']['name'] for tc in collected_tool_calls]}")
3665
+ yield {
3666
+ "type": "tool_execution_start",
3667
+ "tool_calls": [
3668
+ {
3669
+ "name": tc["function"]["name"],
3670
+ "id": tc["id"],
3671
+ "function": {
3672
+ "name": tc["function"]["name"],
3673
+ "arguments": tc["function"].get("arguments", "")
3674
+ }
3675
+ } for tc in collected_tool_calls
3676
+ ]
3677
+ }
3678
+
3679
+ tool_results = []
3680
+ for tc in collected_tool_calls:
3681
+ tool_name = tc["function"]["name"]
3682
+ tool_args = tc["function"]["arguments"]
3683
+ tool_id = tc["id"]
3684
+
3685
+ if isinstance(tool_args, str):
3686
+ try:
3687
+ tool_args = json.loads(tool_args) if tool_args.strip() else {}
3688
+ except json.JSONDecodeError:
3689
+ tool_args = {}
3690
+
3691
+ print(f"[MCP] tool_start {tool_name} args={tool_args}")
3692
+ yield {"type": "tool_start", "name": tool_name, "id": tool_id, "args": tool_args}
3693
+ try:
3694
+ tool_content = ""
3695
+ # First, try local Jinx execution
3696
+ if npc_object and hasattr(npc_object, "jinxs_dict") and tool_name in npc_object.jinxs_dict:
3697
+ jinx_obj = npc_object.jinxs_dict[tool_name]
3698
+ try:
3699
+ jinx_ctx = jinx_obj.execute(
3700
+ input_values=tool_args if isinstance(tool_args, dict) else {},
3701
+ npc=npc_object,
3702
+ messages=messages
3703
+ )
3704
+ tool_content = str(jinx_ctx.get("output", jinx_ctx))
3705
+ print(f"[MCP] jinx tool_complete {tool_name}")
3706
+ except Exception as e:
3707
+ raise Exception(f"Jinx execution failed: {e}")
3266
3708
  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
3709
+ try:
3710
+ loop = asyncio.get_event_loop()
3711
+ except RuntimeError:
3712
+ loop = asyncio.new_event_loop()
3713
+ asyncio.set_event_loop(loop)
3714
+ if loop.is_closed():
3715
+ loop = asyncio.new_event_loop()
3716
+ asyncio.set_event_loop(loop)
3717
+ mcp_result = loop.run_until_complete(
3718
+ mcp_client.session.call_tool(tool_name, tool_args)
3719
+ ) if mcp_client else {"error": "No MCP client"}
3720
+ if hasattr(mcp_result, "content") and mcp_result.content:
3721
+ for content_item in mcp_result.content:
3722
+ if hasattr(content_item, "text"):
3723
+ tool_content += content_item.text
3724
+ elif hasattr(content_item, "data"):
3725
+ tool_content += str(content_item.data)
3726
+ else:
3727
+ tool_content += str(content_item)
3728
+ else:
3729
+ tool_content = str(mcp_result)
3730
+
3731
+ tool_results.append({
3732
+ "role": "tool",
3733
+ "tool_call_id": tool_id,
3734
+ "name": tool_name,
3735
+ "content": tool_content
3736
+ })
3737
+
3738
+ print(f"[MCP] tool_complete {tool_name}")
3739
+ yield {"type": "tool_complete", "name": tool_name, "id": tool_id, "result_preview": tool_content[:4000]}
3740
+ except Exception as e:
3741
+ err_msg = f"Error executing {tool_name}: {e}"
3742
+ tool_results.append({
3743
+ "role": "tool",
3744
+ "tool_call_id": tool_id,
3745
+ "name": tool_name,
3746
+ "content": err_msg
3747
+ })
3748
+ print(f"[MCP] tool_error {tool_name}: {e}")
3749
+ yield {"type": "tool_error", "name": tool_name, "id": tool_id, "error": str(e)}
3750
+
3751
+ serialized_tool_calls = []
3752
+ for tc in collected_tool_calls:
3753
+ parsed_args = tc["function"]["arguments"]
3754
+ # Gemini/LLM expects arguments as JSON string, not dict
3755
+ if isinstance(parsed_args, dict):
3756
+ args_for_message = json.dumps(parsed_args)
3757
+ else:
3758
+ args_for_message = str(parsed_args)
3759
+ serialized_tool_calls.append({
3760
+ "id": tc["id"],
3761
+ "type": tc["type"],
3762
+ "function": {
3763
+ "name": tc["function"]["name"],
3764
+ "arguments": args_for_message
3765
+ }
3766
+ })
3269
3767
 
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
- )
3768
+ messages.append({
3769
+ "role": "assistant",
3770
+ "content": collected_content,
3771
+ "tool_calls": serialized_tool_calls
3772
+ })
3773
+ messages.extend(tool_results)
3774
+ tool_results_for_db = tool_results
3775
+
3776
+ prompt = ""
3276
3777
 
3277
- app.corca_states[state_key] = state
3278
- messages = state.messages
3778
+ app.mcp_clients[state_key]["messages"] = messages
3779
+ return
3780
+
3781
+ stream_response = stream_mcp_sse()
3279
3782
 
3280
3783
  else:
3281
3784
  stream_response = {"output": f"Unsupported execution mode: {exe_mode}", "messages": messages}
@@ -3316,6 +3819,36 @@ def stream():
3316
3819
  tool_call_data = {"id": None, "function_name": None, "arguments": ""}
3317
3820
 
3318
3821
  try:
3822
+ # New: handle generators (tool_agent streaming)
3823
+ if hasattr(stream_response, "__iter__") and not isinstance(stream_response, (dict, str)):
3824
+ for chunk in stream_response:
3825
+ with cancellation_lock:
3826
+ if cancellation_flags.get(current_stream_id, False):
3827
+ interrupted = True
3828
+ break
3829
+ if chunk is None:
3830
+ continue
3831
+ if isinstance(chunk, dict):
3832
+ if chunk.get("type") == "interrupt":
3833
+ interrupted = True
3834
+ break
3835
+ yield f"data: {json.dumps(chunk)}\n\n"
3836
+ if chunk.get("choices"):
3837
+ for choice in chunk["choices"]:
3838
+ delta = choice.get("delta", {})
3839
+ content_piece = delta.get("content")
3840
+ if content_piece:
3841
+ complete_response.append(content_piece)
3842
+ continue
3843
+ yield f"data: {json.dumps({'choices':[{'delta':{'content': str(chunk), 'role': 'assistant'},'finish_reason':None}]})}\n\n"
3844
+ # ensure stream termination and cleanup for generator flows
3845
+ yield "data: [DONE]\n\n"
3846
+ with cancellation_lock:
3847
+ if current_stream_id in cancellation_flags:
3848
+ del cancellation_flags[current_stream_id]
3849
+ print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
3850
+ return
3851
+
3319
3852
  if isinstance(stream_response, str) :
3320
3853
  print('stream a str and not a gen')
3321
3854
  chunk_data = {
@@ -3429,6 +3962,36 @@ def stream():
3429
3962
  # Yield message_stop immediately so the client's stream ends quickly
3430
3963
  yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
3431
3964
 
3965
+ # Persist tool call metadata and results before final assistant content
3966
+ if tool_call_data.get("function_name") or tool_call_data.get("arguments"):
3967
+ save_conversation_message(
3968
+ command_history,
3969
+ conversation_id,
3970
+ "assistant",
3971
+ {"tool_call": tool_call_data},
3972
+ wd=current_path,
3973
+ model=model,
3974
+ provider=provider,
3975
+ npc=npc_name,
3976
+ team=team,
3977
+ message_id=generate_message_id(),
3978
+ )
3979
+
3980
+ if tool_results_for_db:
3981
+ for tr in tool_results_for_db:
3982
+ save_conversation_message(
3983
+ command_history,
3984
+ conversation_id,
3985
+ "tool",
3986
+ {"tool_name": tr.get("name"), "tool_call_id": tr.get("tool_call_id"), "content": tr.get("content")},
3987
+ wd=current_path,
3988
+ model=model,
3989
+ provider=provider,
3990
+ npc=npc_name,
3991
+ team=team,
3992
+ message_id=generate_message_id(),
3993
+ )
3994
+
3432
3995
  # Save assistant message to the database
3433
3996
  npc_name_to_save = npc_object.name if npc_object else ''
3434
3997
  save_conversation_message(
@@ -3682,6 +4245,37 @@ def ollama_status():
3682
4245
  return jsonify({"status": "not_found"})
3683
4246
 
3684
4247
 
4248
+ @app.route("/api/ollama/tool_models", methods=["GET"])
4249
+ def get_ollama_tool_models():
4250
+ """
4251
+ Best-effort detection of Ollama models whose templates include tool-call support.
4252
+ We scan templates for tool placeholders; if none are found we assume tools are unsupported.
4253
+ """
4254
+ try:
4255
+ detected = []
4256
+ listing = ollama.list()
4257
+ for model in listing.get("models", []):
4258
+ name = getattr(model, "model", None) or model.get("name") if isinstance(model, dict) else None
4259
+ if not name:
4260
+ continue
4261
+ try:
4262
+ details = ollama.show(name)
4263
+ tmpl = details.get("template") or ""
4264
+ if "{{- if .Tools" in tmpl or "{{- range .Tools" in tmpl or "{{- if .ToolCalls" in tmpl:
4265
+ detected.append(name)
4266
+ continue
4267
+ metadata = details.get("metadata") or {}
4268
+ if metadata.get("tools") or metadata.get("tool_calls"):
4269
+ detected.append(name)
4270
+ except Exception as inner_e:
4271
+ print(f"Warning: could not inspect ollama model {name} for tool support: {inner_e}")
4272
+ continue
4273
+ return jsonify({"models": detected, "error": None})
4274
+ except Exception as e:
4275
+ print(f"Error listing Ollama tool-capable models: {e}")
4276
+ return jsonify({"models": [], "error": str(e)}), 500
4277
+
4278
+
3685
4279
  @app.route('/api/ollama/models', methods=['GET'])
3686
4280
  def get_ollama_models():
3687
4281
  response = ollama.list()