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/npc_compiler.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shutil
2
3
  from pyexpat.errors import messages
3
4
  import yaml
4
5
  import json
@@ -18,7 +19,6 @@ from typing import Any, Dict, List, Optional, Union, Callable, Tuple
18
19
  from jinja2 import Environment, FileSystemLoader, Template, Undefined, DictLoader
19
20
  from sqlalchemy import create_engine, text
20
21
  import npcpy as npy
21
- from npcpy.llm_funcs import DEFAULT_ACTION_SPACE
22
22
  from npcpy.tools import auto_tools
23
23
  import math
24
24
  import random
@@ -183,6 +183,7 @@ def initialize_npc_project(
183
183
  """Initialize an NPC project"""
184
184
  if directory is None:
185
185
  directory = os.getcwd()
186
+ directory = os.path.expanduser(os.fspath(directory))
186
187
 
187
188
  npc_team_dir = os.path.join(directory, "npc_team")
188
189
  os.makedirs(npc_team_dir, exist_ok=True)
@@ -191,7 +192,8 @@ def initialize_npc_project(
191
192
  "assembly_lines",
192
193
  "sql_models",
193
194
  "jobs",
194
- "triggers"]:
195
+ "triggers",
196
+ "tools"]:
195
197
  os.makedirs(os.path.join(npc_team_dir, subdir), exist_ok=True)
196
198
 
197
199
  forenpc_path = os.path.join(npc_team_dir, "forenpc.npc")
@@ -206,20 +208,166 @@ def initialize_npc_project(
206
208
  }
207
209
  with open(forenpc_path, "w") as f:
208
210
  yaml.dump(default_npc, f)
209
- ctx_path = os.path.join(npc_team_dir, "team.ctx")
210
- if not os.path.exists(ctx_path):
211
+ parsed_templates: List[str] = []
212
+ if templates:
213
+ if isinstance(templates, str):
214
+ parsed_templates = [
215
+ t.strip() for t in re.split(r"[,\s]+", templates) if t.strip()
216
+ ]
217
+ elif isinstance(templates, (list, tuple, set)):
218
+ parsed_templates = [str(t).strip() for t in templates if str(t).strip()]
219
+ else:
220
+ parsed_templates = [str(templates).strip()]
221
+
222
+ ctx_destination: Optional[str] = None
223
+ preexisting_ctx = [
224
+ os.path.join(npc_team_dir, f)
225
+ for f in os.listdir(npc_team_dir)
226
+ if f.endswith(".ctx")
227
+ ]
228
+ if preexisting_ctx:
229
+ ctx_destination = preexisting_ctx[0]
230
+ if len(preexisting_ctx) > 1:
231
+ print(
232
+ "Warning: Multiple .ctx files already present; using first and ignoring the rest."
233
+ )
234
+
235
+ def _resolve_template_path(template_name: str) -> Optional[str]:
236
+ expanded = os.path.expanduser(template_name)
237
+ if os.path.exists(expanded):
238
+ return expanded
239
+
240
+ embedded_templates = {
241
+ "slean": """name: slean
242
+ primary_directive: You are slean, the marketing innovator AI. Your responsibility is to create marketing campaigns and manage them effectively, while also thinking creatively to solve marketing challenges. Guide the strategy that drives customer engagement and brand awareness.
243
+ """,
244
+ "turnic": """name: turnic
245
+ primary_directive: Assist with sales challenges and questions. Opt for straightforward solutions that help sales professionals achieve quick results.
246
+ """,
247
+ "budgeto": """name: budgeto
248
+ primary_directive: You manage marketing budgets, ensuring resources are allocated efficiently and spend is optimized.
249
+ """,
250
+ "relatio": """name: relatio
251
+ primary_directive: You manage customer relationships and ensure satisfaction throughout the sales process. Focus on nurturing clients and maintaining long-term connections.
252
+ """,
253
+ "funnel": """name: funnel
254
+ primary_directive: You oversee the sales pipeline, track progress, and optimize conversion rates to move leads efficiently.
255
+ """,
256
+ }
257
+
258
+ base_dirs = [
259
+ os.path.expanduser("~/.npcsh/npc_team/templates"),
260
+ os.path.expanduser("~/.npcpy/npc_team/templates"),
261
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "tests", "template_tests", "npc_team")),
262
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "examples", "npc_team")),
263
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "example_npc_project", "npc_team")),
264
+ ]
265
+ base_dirs = [d for d in base_dirs if os.path.isdir(d)]
266
+
267
+ for base in base_dirs:
268
+ direct = os.path.join(base, template_name)
269
+ if os.path.exists(direct):
270
+ return direct
271
+ if not direct.endswith(".npc") and os.path.exists(direct + ".npc"):
272
+ return direct + ".npc"
273
+ for root, _, files in os.walk(base):
274
+ for fname in files:
275
+ stem, ext = os.path.splitext(fname)
276
+ if ext == ".npc" and stem == template_name:
277
+ return os.path.join(root, fname)
278
+
279
+ # If no on-disk template found, fall back to embedded definitions
280
+ if template_name in embedded_templates:
281
+ embedded_dir = os.path.join(npc_team_dir, "_embedded_templates", template_name)
282
+ os.makedirs(embedded_dir, exist_ok=True)
283
+ npc_file = os.path.join(embedded_dir, f"{template_name}.npc")
284
+ if not os.path.exists(npc_file):
285
+ with open(npc_file, "w") as f:
286
+ f.write(embedded_templates[template_name])
287
+ return embedded_dir
288
+ return None
289
+
290
+ def _copy_template(src_path: str) -> List[str]:
291
+ nonlocal ctx_destination
292
+ copied: List[str] = []
293
+ src_path = os.path.expanduser(src_path)
294
+
295
+ allowed_exts = {".npc", ".tool", ".pipe", ".sql", ".job", ".ctx", ".yaml", ".yml"}
296
+
297
+ if os.path.isfile(src_path):
298
+ if os.path.splitext(src_path)[1] in allowed_exts:
299
+ if os.path.splitext(src_path)[1] == ".ctx":
300
+ if ctx_destination:
301
+ print(
302
+ f"Warning: Skipping extra context file '{src_path}' because one already exists."
303
+ )
304
+ return copied
305
+ dest_path = os.path.join(npc_team_dir, os.path.basename(src_path))
306
+ ctx_destination = dest_path
307
+ else:
308
+ dest_path = os.path.join(npc_team_dir, os.path.basename(src_path))
309
+ if not os.path.exists(dest_path):
310
+ shutil.copy2(src_path, dest_path)
311
+ copied.append(dest_path)
312
+ return copied
313
+
314
+ for root, _, files in os.walk(src_path):
315
+ rel_dir = os.path.relpath(root, src_path)
316
+ dest_dir = npc_team_dir if rel_dir == "." else os.path.join(npc_team_dir, rel_dir)
317
+ os.makedirs(dest_dir, exist_ok=True)
318
+ for fname in files:
319
+ if os.path.splitext(fname)[1] not in allowed_exts:
320
+ continue
321
+ if os.path.splitext(fname)[1] == ".ctx":
322
+ if ctx_destination:
323
+ print(
324
+ f"Warning: Skipping extra context file '{os.path.join(root, fname)}' because one already exists."
325
+ )
326
+ continue
327
+ dest_path = os.path.join(npc_team_dir, fname)
328
+ ctx_destination = dest_path
329
+ else:
330
+ dest_path = os.path.join(dest_dir, fname)
331
+ if not os.path.exists(dest_path):
332
+ shutil.copy2(os.path.join(root, fname), dest_path)
333
+ copied.append(dest_path)
334
+ return copied
335
+
336
+ applied_templates: List[str] = []
337
+ if parsed_templates:
338
+ for template_name in parsed_templates:
339
+ template_path = _resolve_template_path(template_name)
340
+ if not template_path:
341
+ print(f"Warning: Template '{template_name}' not found in known template directories.")
342
+ continue
343
+ copied = _copy_template(template_path)
344
+ if copied:
345
+ applied_templates.append(template_name)
346
+
347
+ if applied_templates:
348
+ applied_templates = sorted(set(applied_templates))
349
+ if not ctx_destination:
350
+ default_ctx_path = os.path.join(npc_team_dir, "team.ctx")
211
351
  default_ctx = {
212
352
  'name': '',
213
- 'context' : '',
353
+ 'context' : context or '',
214
354
  'preferences': '',
215
355
  'mcp_servers': '',
216
356
  'databases':'',
217
357
  'use_global_jinxs': True,
218
358
  'forenpc': 'forenpc'
219
359
  }
220
- with open(ctx_path, "w") as f:
360
+ if parsed_templates:
361
+ default_ctx['templates'] = parsed_templates
362
+ with open(default_ctx_path, "w") as f:
221
363
  yaml.dump(default_ctx, f)
222
-
364
+ ctx_destination = default_ctx_path
365
+
366
+ if applied_templates:
367
+ return (
368
+ f"NPC project initialized in {npc_team_dir} "
369
+ f"using templates: {', '.join(applied_templates)}"
370
+ )
223
371
  return f"NPC project initialized in {npc_team_dir}"
224
372
 
225
373
 
@@ -286,6 +434,34 @@ class Jinx:
286
434
  self.steps = jinx_data.get("steps", [])
287
435
  self._source_path = jinx_data.get("_source_path", None)
288
436
 
437
+ def to_tool_def(self) -> Dict[str, Any]:
438
+ """Convert this Jinx to an OpenAI-style tool definition."""
439
+ properties = {}
440
+ required = []
441
+ for inp in self.inputs:
442
+ if isinstance(inp, str):
443
+ properties[inp] = {"type": "string", "description": f"Parameter: {inp}"}
444
+ required.append(inp)
445
+ elif isinstance(inp, dict):
446
+ name = list(inp.keys())[0]
447
+ default_val = inp.get(name, "")
448
+ desc = f"Parameter: {name}"
449
+ if default_val != "":
450
+ desc += f" (default: {default_val})"
451
+ properties[name] = {"type": "string", "description": desc}
452
+ return {
453
+ "type": "function",
454
+ "function": {
455
+ "name": self.jinx_name,
456
+ "description": self.description or f"Jinx: {self.jinx_name}",
457
+ "parameters": {
458
+ "type": "object",
459
+ "properties": properties,
460
+ "required": required
461
+ }
462
+ }
463
+ }
464
+
289
465
  def render_first_pass(
290
466
  self,
291
467
  jinja_env_for_macros: Environment,
@@ -619,73 +795,14 @@ def load_jinxs_from_directory(directory):
619
795
 
620
796
  return jinxs
621
797
 
622
- def get_npc_action_space(npc=None, team=None):
623
- """Get action space for NPC including memory CRUD and core capabilities"""
624
- actions = DEFAULT_ACTION_SPACE.copy()
625
-
626
- if npc:
627
- core_tools = [
628
- npc.think_step_by_step,
629
- ]
630
- if hasattr(npc, "write_code"):
631
- core_tools.append(npc.write_code)
632
-
633
- if npc.command_history:
634
- core_tools.extend([
635
- npc.search_my_conversations,
636
- npc.search_my_memories,
637
- npc.create_memory,
638
- npc.read_memory,
639
- npc.update_memory,
640
- npc.delete_memory,
641
- npc.search_memories,
642
- npc.get_all_memories,
643
- npc.archive_old_memories,
644
- npc.get_memory_stats
645
- ])
646
-
647
- if npc.db_conn:
648
- core_tools.append(npc.query_database)
649
-
650
- if hasattr(npc, 'tools') and npc.tools:
651
- core_tools.extend([func for func in npc.tool_map.values() if callable(func)])
652
-
653
- if core_tools:
654
- tools_schema, tool_map = auto_tools(core_tools)
655
- actions.update({
656
- f"use_{tool.__name__}": {
657
- "description": f"Use {tool.__name__} capability",
658
- "handler": tool,
659
- "context": lambda **_: f"Available as automated capability",
660
- "output_keys": {"result": {"description": "Tool execution result", "type": "string"}}
661
- }
662
- for tool in core_tools
663
- })
664
-
665
- if team and hasattr(team, 'npcs') and len(team.npcs) > 1:
666
- available_npcs = [name for name in team.npcs.keys() if name != (npc.name if npc else None)]
667
-
668
- def team_aware_handler(command, extracted_data, **kwargs):
669
- if 'team' not in kwargs or kwargs['team'] is None:
670
- kwargs['team'] = team
671
- return agent_pass_handler(command, extracted_data, **kwargs)
672
-
673
- actions["pass_to_npc"] = {
674
- "description": "Pass request to another NPC - only when task requires their specific expertise",
675
- "handler": team_aware_handler,
676
- "context": lambda npc=npc, team=team, **_: (
677
- f"Available NPCs: {', '.join(available_npcs)}. "
678
- f"Only pass when you genuinely cannot complete the task."
679
- ),
680
- "output_keys": {
681
- "target_npc": {
682
- "description": "Name of the NPC to pass the request to",
683
- "type": "string"
684
- }
685
- }
686
- }
687
-
688
- return actions
798
+ def jinx_to_tool_def(jinx_obj: 'Jinx') -> Dict[str, Any]:
799
+ """Convert a Jinx instance into an MCP/LLM-compatible tool schema definition."""
800
+ return jinx_obj.to_tool_def()
801
+
802
+ def build_jinx_tool_catalog(jinxs: Dict[str, 'Jinx']) -> Dict[str, Dict[str, Any]]:
803
+ """Helper to build a name->tool_def catalog from a dict of Jinx objects."""
804
+ return {name: jinx_to_tool_def(jinx_obj) for name, jinx_obj in jinxs.items()}
805
+
689
806
  def extract_jinx_inputs(args: List[str], jinx: Jinx) -> Dict[str, Any]:
690
807
  print(f"DEBUG extract_jinx_inputs called with args: {args}")
691
808
  print(f"DEBUG jinx.inputs: {jinx.inputs}")
@@ -838,7 +955,9 @@ class NPC:
838
955
  self.npc_directory = None
839
956
 
840
957
  self.team = team # Store the team reference (can be None)
841
- self.jinxs_spec = jinxs or "*" # Store the jinx specification for later loading
958
+ # Only set jinxs_spec from parameter if it wasn't already set by _load_from_file
959
+ if not hasattr(self, 'jinxs_spec') or jinxs is not None:
960
+ self.jinxs_spec = jinxs or "*" # Store the jinx specification for later loading
842
961
 
843
962
  if tools is not None:
844
963
  tools_schema, tool_map = auto_tools(tools)
@@ -851,6 +970,8 @@ class NPC:
851
970
  self.tools_schema = []
852
971
  self.plain_system_message = plain_system_message
853
972
  self.use_global_jinxs = use_global_jinxs
973
+ self.jinx_tool_catalog: Dict[str, Dict[str, Any]] = {}
974
+ self.mcp_servers = []
854
975
 
855
976
  self.memory_length = 20
856
977
  self.memory_strategy = 'recent'
@@ -886,26 +1007,45 @@ class NPC:
886
1007
  # If jinxs are explicitly provided *to the NPC* during its standalone creation, load them.
887
1008
  # This is for NPCs created *outside* a team context initially.
888
1009
  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.")
1010
+ for jinx_item in jinxs:
1011
+ if isinstance(jinx_item, Jinx):
1012
+ self.jinxs_dict[jinx_item.jinx_name] = jinx_item
1013
+ elif isinstance(jinx_item, dict):
1014
+ jinx_obj = Jinx(jinx_data=jinx_item)
1015
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
1016
+ elif isinstance(jinx_item, str):
1017
+ # Try to load from NPC's own directory first
1018
+ jinx_path = find_file_path(jinx_item, [self.npc_jinxs_directory], suffix=".jinx")
1019
+ if jinx_path:
1020
+ jinx_obj = Jinx(jinx_path=jinx_path)
1021
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
1022
+ else:
1023
+ print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
903
1024
 
904
1025
  self.shared_context = {
1026
+ # Data analysis (guac)
905
1027
  "dataframes": {},
906
1028
  "current_data": None,
907
1029
  "computation_results": [],
908
- "memories":{}
1030
+ "locals": {}, # Python exec locals for guac mode
1031
+
1032
+ # Memory
1033
+ "memories": {},
1034
+
1035
+ # MCP tools (corca)
1036
+ "mcp_client": None,
1037
+ "mcp_tools": [],
1038
+ "mcp_tool_map": {},
1039
+
1040
+ # Session tracking
1041
+ "session_input_tokens": 0,
1042
+ "session_output_tokens": 0,
1043
+ "session_cost_usd": 0.0,
1044
+ "turn_count": 0,
1045
+
1046
+ # Mode state
1047
+ "current_mode": "agent",
1048
+ "attachments": [],
909
1049
  }
910
1050
 
911
1051
  for key, value in kwargs.items():
@@ -977,7 +1117,8 @@ class NPC:
977
1117
  except Exception as e:
978
1118
  print(f"Error performing first-pass rendering for NPC Jinx '{raw_npc_jinx.jinx_name}': {e}")
979
1119
 
980
- print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs.")
1120
+ self.jinx_tool_catalog = build_jinx_tool_catalog(self.jinxs_dict)
1121
+ print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs and built catalog with {len(self.jinx_tool_catalog)} tools.")
981
1122
 
982
1123
  def _load_npc_kg(self):
983
1124
  """Load knowledge graph data for this NPC from database"""
@@ -1683,35 +1824,41 @@ class NPC:
1683
1824
  jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
1684
1825
  )
1685
1826
 
1686
- if self.db_conn is not None:
1687
- self.db_conn.add_jinx_call(
1688
- triggering_message_id=message_id,
1689
- conversation_id=conversation_id,
1690
- jinx_name=jinx_name,
1691
- jinx_inputs=inputs,
1692
- jinx_output=result,
1693
- status="success",
1694
- error_message=None,
1695
- duration_ms=None,
1696
- npc_name=self.name,
1697
- team_name=team_name,
1698
- )
1827
+ # Log jinx call if we have a command_history with add_jinx_call method
1828
+ if self.command_history is not None and hasattr(self.command_history, 'add_jinx_call'):
1829
+ try:
1830
+ self.command_history.add_jinx_call(
1831
+ triggering_message_id=message_id,
1832
+ conversation_id=conversation_id,
1833
+ jinx_name=jinx_name,
1834
+ jinx_inputs=inputs,
1835
+ jinx_output=result,
1836
+ status="success",
1837
+ error_message=None,
1838
+ duration_ms=None,
1839
+ npc_name=self.name,
1840
+ team_name=team_name,
1841
+ )
1842
+ except Exception:
1843
+ pass # Don't fail jinx execution due to logging error
1699
1844
  return result
1700
1845
  def check_llm_command(self,
1701
- command,
1846
+ command,
1702
1847
  messages=None,
1703
1848
  context=None,
1704
1849
  team=None,
1705
- stream=False):
1850
+ stream=False,
1851
+ jinxs=None):
1706
1852
  """Check if a command is for the LLM"""
1707
1853
  if context is None:
1708
1854
  context = self.shared_context
1709
-
1855
+
1710
1856
  if team:
1711
1857
  self._current_team = team
1712
-
1713
- actions = get_npc_action_space(npc=self, team=team)
1714
-
1858
+
1859
+ # Use provided jinxs or fall back to NPC's own jinxs
1860
+ jinxs_to_use = jinxs if jinxs is not None else self.jinxs_dict
1861
+
1715
1862
  return npy.llm_funcs.check_llm_command(
1716
1863
  command,
1717
1864
  model=self.model,
@@ -1721,7 +1868,7 @@ class NPC:
1721
1868
  messages=self.memory if messages is None else messages,
1722
1869
  context=context,
1723
1870
  stream=stream,
1724
- actions=actions
1871
+ jinxs=jinxs_to_use,
1725
1872
  )
1726
1873
 
1727
1874
  def handle_agent_pass(self,
@@ -2004,6 +2151,7 @@ class Team:
2004
2151
  self.sub_teams: Dict[str, 'Team'] = {}
2005
2152
  self.jinxs_dict: Dict[str, 'Jinx'] = {} # This will store first-pass rendered Jinx objects
2006
2153
  self._raw_jinxs_list: List['Jinx'] = [] # Temporary storage for raw Team-level Jinx objects
2154
+ self.jinx_tool_catalog: Dict[str, Dict[str, Any]] = {} # Jinx-derived tool defs ready for MCP/LLM
2007
2155
 
2008
2156
  self.jinja_env_for_first_pass = Environment(undefined=SilentUndefined) # Env for macro expansion
2009
2157
 
@@ -2059,6 +2207,8 @@ class Team:
2059
2207
 
2060
2208
  # Perform first-pass rendering for team-level jinxs
2061
2209
  self._perform_first_pass_jinx_rendering()
2210
+ self.jinx_tool_catalog = build_jinx_tool_catalog(self.jinxs_dict)
2211
+ print(f"[TEAM] Built Jinx tool catalog with {len(self.jinx_tool_catalog)} entries for team {self.name}")
2062
2212
 
2063
2213
  # Now, initialize jinxs for all NPCs, as team-level jinxs are ready
2064
2214
  for npc_obj in self.npcs.values():
@@ -2135,13 +2285,9 @@ class Team:
2135
2285
  if 'file_patterns' in ctx_data:
2136
2286
  file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
2137
2287
  self.shared_context['files'] = file_cache
2138
- if 'preferences' in ctx_data:
2139
- self.preferences = ctx_data['preferences']
2140
- else:
2141
- self.preferences = []
2142
-
2288
+ # All other keys (including preferences) are treated as generic context
2143
2289
  for key, item in ctx_data.items():
2144
- if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env', 'preferences']:
2290
+ if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env']:
2145
2291
  self.shared_context[key] = item
2146
2292
  return # Only load the first .ctx file found
2147
2293
 
@@ -2236,8 +2382,6 @@ class Team:
2236
2382
  self.jinxs_dict[raw_jinx.jinx_name] = raw_jinx # Store the first-pass rendered Jinx
2237
2383
  except Exception as e:
2238
2384
  print(f"Error performing first-pass rendering for Jinx '{raw_jinx.jinx_name}': {e}")
2239
-
2240
- self._raw_jinxs_list = [] # Clear temporary storage
2241
2385
 
2242
2386
 
2243
2387
  def update_context(self, messages: list):
@@ -2323,146 +2467,98 @@ class Team:
2323
2467
  else:
2324
2468
  return None
2325
2469
 
2326
- def orchestrate(self, request):
2470
+ def orchestrate(self, request, max_iterations=3):
2327
2471
  """Orchestrate a request through the team"""
2328
- forenpc = self.get_forenpc() # Now guaranteed to be an NPC object
2472
+ import re
2473
+ from termcolor import colored
2474
+
2475
+ forenpc = self.get_forenpc()
2329
2476
  if not forenpc:
2330
2477
  return {"error": "No forenpc available to coordinate the team"}
2331
-
2332
- log_entry(
2333
- self.name,
2334
- "orchestration_start",
2335
- {"request": request}
2336
- )
2337
-
2338
- result = forenpc.check_llm_command(request,
2339
- context=getattr(self, 'context', {}),
2340
- team = self,
2341
- )
2342
-
2343
- while True:
2344
- completion_prompt= ""
2345
- if isinstance(result, dict):
2346
- self.shared_context["execution_history"].append(result)
2347
-
2348
- if result.get("messages") and result.get("npc_name"):
2349
- if result["npc_name"] not in self.shared_context["npc_messages"]:
2350
- self.shared_context["npc_messages"][result["npc_name"]] = []
2351
- self.shared_context["npc_messages"][result["npc_name"]].extend(
2352
- result["messages"]
2353
- )
2354
-
2355
- completion_prompt += f"""Context:
2356
- User request '{request}', previous agent
2357
-
2358
- previous agent returned:
2359
- {result.get('output')}
2360
2478
 
2361
-
2362
- Instructions:
2479
+ print(colored(f"[orchestrate] Starting with forenpc={forenpc.name}, team={self.name}", "cyan"))
2480
+ print(colored(f"[orchestrate] Request: {request[:100]}...", "cyan"))
2363
2481
 
2364
- Check whether the response is relevant to the user's request.
2482
+ # Filter out 'orchestrate' jinx to prevent infinite recursion
2483
+ jinxs_for_orchestration = {k: v for k, v in forenpc.jinxs_dict.items() if k != 'orchestrate'}
2365
2484
 
2366
- """
2367
- if self.npcs is None or len(self.npcs) == 0:
2368
- completion_prompt += f"""
2369
- The team has no members, so the forenpc must handle the request alone.
2370
- """
2371
- else:
2372
- completion_prompt += f"""
2373
-
2374
- These are all the members of the team: {', '.join(self.npcs.keys())}
2375
-
2376
- Therefore, if you are trying to evaluate whether a request was fulfilled relevantly,
2377
- consider that requests are made to the forenpc: {forenpc.name}
2378
- and that the forenpc must pass those along to the other npcs.
2379
- """
2380
- completion_prompt += f"""
2381
-
2382
- Mainly concern yourself with ensuring there are no
2383
- glaring errors nor fundamental mishaps in the response.
2384
- Do not consider stylistic hiccups as the answers being
2385
- irrelevant. By providing responses back to for the user to
2386
- comment on, they can can more efficiently iterate and resolve any issues by
2387
- prompting more clearly.
2388
- natural language itself is very fuzzy so there will always be some level
2389
- of misunderstanding, but as long as the response is clearly relevant
2390
- to the input request and along the user's intended direction,
2391
- it is considered relevant.
2392
-
2393
-
2394
- If there is enough information to begin a fruitful conversation with the user,
2395
- please consider the request relevant so that we do not
2396
- arbritarily stall business logic which is more efficiently
2397
- determined by iterations than through unnecessary pedantry.
2398
-
2399
- It is more important to get a response to the user
2400
- than to account for all edge cases, so as long as the response more or less tackles the
2401
- initial problem to first order, consider it relevant.
2402
-
2403
- Return a JSON object with:
2404
- -'relevant' with boolean value
2405
- -'explanation' for irrelevance with quoted citations in your explanation noting why it is irrelevant to user input must be a single string.
2406
- Return only the JSON object."""
2407
-
2408
- completion_check = npy.llm_funcs.get_llm_response(
2409
- completion_prompt,
2410
- model=forenpc.model,
2411
- provider=forenpc.provider,
2412
- api_key=forenpc.api_key,
2413
- api_url=forenpc.api_url,
2414
- npc=forenpc,
2415
- format="json"
2485
+ try:
2486
+ result = forenpc.check_llm_command(
2487
+ request,
2488
+ context=getattr(self, 'context', {}),
2489
+ team=self,
2490
+ jinxs=jinxs_for_orchestration,
2416
2491
  )
2417
-
2418
- if isinstance(completion_check.get("response"), dict):
2419
- complete = completion_check["response"].get("relevant", False)
2420
- explanation = completion_check["response"].get("explanation", "")
2421
- else:
2422
- complete = False
2423
- explanation = "Could not determine completion status"
2424
-
2425
- if complete:
2426
- debrief = npy.llm_funcs.get_llm_response(
2427
- f"""Context:
2428
- Original request: {request}
2429
- Execution history: {self.shared_context['execution_history']}
2430
-
2431
- Instructions:
2432
- Provide summary of actions taken and recommendations.
2433
- Return a JSON object with:
2434
- - 'summary': Overview of what was accomplished
2435
- - 'recommendations': Suggested next steps
2436
- Return only the JSON object.""",
2437
- model=forenpc.model,
2438
- provider=forenpc.provider,
2439
- api_key=forenpc.api_key,
2440
- api_url=forenpc.api_url,
2441
- npc=forenpc,
2442
- format="json"
2443
- )
2444
-
2445
- return {
2446
- "debrief": debrief.get("response"),
2447
- "output": result.get("output"),
2448
- "execution_history": self.shared_context["execution_history"],
2449
- }
2450
- else:
2451
- updated_request = (
2452
- request
2453
- + "\n\nThe request has not yet been fully completed. "
2454
- + explanation
2455
- + "\nPlease address only the remaining parts of the request."
2456
- )
2457
- print('updating request', updated_request)
2458
-
2459
- result = forenpc.check_llm_command(
2460
- updated_request,
2461
- context=getattr(self, 'context', {}),
2462
- stream = False,
2463
- team = self
2464
-
2465
- )
2492
+ print(colored(f"[orchestrate] Initial result type={type(result)}", "cyan"))
2493
+ if isinstance(result, dict):
2494
+ print(colored(f"[orchestrate] Result keys={list(result.keys())}", "cyan"))
2495
+ if 'error' in result:
2496
+ print(colored(f"[orchestrate] Error in result: {result['error']}", "red"))
2497
+ return result
2498
+ except Exception as e:
2499
+ print(colored(f"[orchestrate] Exception in check_llm_command: {e}", "red"))
2500
+ return {"error": str(e), "output": f"Orchestration failed: {e}"}
2501
+
2502
+ # Check if forenpc mentioned other team members - if so, delegate to them
2503
+ output = ""
2504
+ if isinstance(result, dict):
2505
+ output = result.get('output') or result.get('response') or ""
2506
+
2507
+ print(colored(f"[orchestrate] Output preview: {output[:200] if output else 'EMPTY'}...", "cyan"))
2508
+
2509
+ if output and self.npcs:
2510
+ # Look for @npc_name mentions OR just npc names
2511
+ at_pattern = r'@(\w+)'
2512
+ mentions = re.findall(at_pattern, output)
2513
+
2514
+ # Also check for NPC names mentioned without @ (case insensitive)
2515
+ if not mentions:
2516
+ for npc_name in self.npcs.keys():
2517
+ if npc_name.lower() != forenpc.name.lower():
2518
+ if npc_name.lower() in output.lower():
2519
+ mentions.append(npc_name)
2520
+ break
2521
+
2522
+ print(colored(f"[orchestrate] Found mentions: {mentions}", "cyan"))
2523
+
2524
+ for mentioned in mentions:
2525
+ mentioned_lower = mentioned.lower()
2526
+ if mentioned_lower in self.npcs and mentioned_lower != forenpc.name:
2527
+ target_npc = self.npcs[mentioned_lower]
2528
+ print(colored(f"[orchestrate] Delegating to @{mentioned_lower}", "yellow"))
2529
+
2530
+ try:
2531
+ # Execute the request with the target NPC (exclude orchestrate to prevent loops)
2532
+ target_jinxs = {k: v for k, v in target_npc.jinxs_dict.items() if k != 'orchestrate'}
2533
+ delegate_result = target_npc.check_llm_command(
2534
+ request,
2535
+ context=getattr(self, 'context', {}),
2536
+ team=self,
2537
+ jinxs=target_jinxs,
2538
+ )
2539
+
2540
+ if isinstance(delegate_result, dict):
2541
+ delegate_output = delegate_result.get('output') or delegate_result.get('response') or ""
2542
+ if delegate_output:
2543
+ output = f"[{mentioned_lower}]: {delegate_output}"
2544
+ result = delegate_result
2545
+ print(colored(f"[orchestrate] Got response from {mentioned_lower}", "green"))
2546
+ except Exception as e:
2547
+ print(colored(f"[orchestrate] Delegation to {mentioned_lower} failed: {e}", "red"))
2548
+
2549
+ break # Only delegate to first mentioned NPC
2550
+
2551
+ if isinstance(result, dict):
2552
+ final_output = output if output else str(result)
2553
+ return {
2554
+ "output": final_output,
2555
+ "result": result,
2556
+ }
2557
+ else:
2558
+ return {
2559
+ "output": str(result),
2560
+ "result": result,
2561
+ }
2466
2562
 
2467
2563
  def to_dict(self):
2468
2564
  """Convert team to dictionary representation"""