npcpy 1.2.36__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,
@@ -621,100 +797,12 @@ def load_jinxs_from_directory(directory):
621
797
 
622
798
  def jinx_to_tool_def(jinx_obj: 'Jinx') -> Dict[str, Any]:
623
799
  """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
- }
800
+ return jinx_obj.to_tool_def()
646
801
 
647
802
  def build_jinx_tool_catalog(jinxs: Dict[str, 'Jinx']) -> Dict[str, Dict[str, Any]]:
648
803
  """Helper to build a name->tool_def catalog from a dict of Jinx objects."""
649
804
  return {name: jinx_to_tool_def(jinx_obj) for name, jinx_obj in jinxs.items()}
650
805
 
651
- def get_npc_action_space(npc=None, team=None):
652
- """Get action space for NPC including memory CRUD and core capabilities"""
653
- actions = DEFAULT_ACTION_SPACE.copy()
654
-
655
- if npc:
656
- core_tools = [
657
- npc.think_step_by_step,
658
- ]
659
- if hasattr(npc, "write_code"):
660
- core_tools.append(npc.write_code)
661
-
662
- if npc.command_history:
663
- core_tools.extend([
664
- npc.search_my_conversations,
665
- npc.search_my_memories,
666
- npc.create_memory,
667
- npc.read_memory,
668
- npc.update_memory,
669
- npc.delete_memory,
670
- npc.search_memories,
671
- npc.get_all_memories,
672
- npc.archive_old_memories,
673
- npc.get_memory_stats
674
- ])
675
-
676
- if npc.db_conn:
677
- core_tools.append(npc.query_database)
678
-
679
- if hasattr(npc, 'tools') and npc.tools:
680
- core_tools.extend([func for func in npc.tool_map.values() if callable(func)])
681
-
682
- if core_tools:
683
- tools_schema, tool_map = auto_tools(core_tools)
684
- actions.update({
685
- f"use_{tool.__name__}": {
686
- "description": f"Use {tool.__name__} capability",
687
- "handler": tool,
688
- "context": lambda **_: f"Available as automated capability",
689
- "output_keys": {"result": {"description": "Tool execution result", "type": "string"}}
690
- }
691
- for tool in core_tools
692
- })
693
-
694
- if team and hasattr(team, 'npcs') and len(team.npcs) > 1:
695
- available_npcs = [name for name in team.npcs.keys() if name != (npc.name if npc else None)]
696
-
697
- def team_aware_handler(command, extracted_data, **kwargs):
698
- if 'team' not in kwargs or kwargs['team'] is None:
699
- kwargs['team'] = team
700
- return agent_pass_handler(command, extracted_data, **kwargs)
701
-
702
- actions["pass_to_npc"] = {
703
- "description": "Pass request to another NPC - only when task requires their specific expertise",
704
- "handler": team_aware_handler,
705
- "context": lambda npc=npc, team=team, **_: (
706
- f"Available NPCs: {', '.join(available_npcs)}. "
707
- f"Only pass when you genuinely cannot complete the task."
708
- ),
709
- "output_keys": {
710
- "target_npc": {
711
- "description": "Name of the NPC to pass the request to",
712
- "type": "string"
713
- }
714
- }
715
- }
716
-
717
- return actions
718
806
  def extract_jinx_inputs(args: List[str], jinx: Jinx) -> Dict[str, Any]:
719
807
  print(f"DEBUG extract_jinx_inputs called with args: {args}")
720
808
  print(f"DEBUG jinx.inputs: {jinx.inputs}")
@@ -867,7 +955,9 @@ class NPC:
867
955
  self.npc_directory = None
868
956
 
869
957
  self.team = team # Store the team reference (can be None)
870
- 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
871
961
 
872
962
  if tools is not None:
873
963
  tools_schema, tool_map = auto_tools(tools)
@@ -933,10 +1023,29 @@ class NPC:
933
1023
  print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
934
1024
 
935
1025
  self.shared_context = {
1026
+ # Data analysis (guac)
936
1027
  "dataframes": {},
937
1028
  "current_data": None,
938
1029
  "computation_results": [],
939
- "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": [],
940
1049
  }
941
1050
 
942
1051
  for key, value in kwargs.items():
@@ -1715,35 +1824,41 @@ class NPC:
1715
1824
  jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
1716
1825
  )
1717
1826
 
1718
- if self.db_conn is not None:
1719
- self.db_conn.add_jinx_call(
1720
- triggering_message_id=message_id,
1721
- conversation_id=conversation_id,
1722
- jinx_name=jinx_name,
1723
- jinx_inputs=inputs,
1724
- jinx_output=result,
1725
- status="success",
1726
- error_message=None,
1727
- duration_ms=None,
1728
- npc_name=self.name,
1729
- team_name=team_name,
1730
- )
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
1731
1844
  return result
1732
1845
  def check_llm_command(self,
1733
- command,
1846
+ command,
1734
1847
  messages=None,
1735
1848
  context=None,
1736
1849
  team=None,
1737
- stream=False):
1850
+ stream=False,
1851
+ jinxs=None):
1738
1852
  """Check if a command is for the LLM"""
1739
1853
  if context is None:
1740
1854
  context = self.shared_context
1741
-
1855
+
1742
1856
  if team:
1743
1857
  self._current_team = team
1744
-
1745
- actions = get_npc_action_space(npc=self, team=team)
1746
-
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
+
1747
1862
  return npy.llm_funcs.check_llm_command(
1748
1863
  command,
1749
1864
  model=self.model,
@@ -1753,7 +1868,7 @@ class NPC:
1753
1868
  messages=self.memory if messages is None else messages,
1754
1869
  context=context,
1755
1870
  stream=stream,
1756
- actions=actions
1871
+ jinxs=jinxs_to_use,
1757
1872
  )
1758
1873
 
1759
1874
  def handle_agent_pass(self,
@@ -2170,13 +2285,9 @@ class Team:
2170
2285
  if 'file_patterns' in ctx_data:
2171
2286
  file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
2172
2287
  self.shared_context['files'] = file_cache
2173
- if 'preferences' in ctx_data:
2174
- self.preferences = ctx_data['preferences']
2175
- else:
2176
- self.preferences = []
2177
-
2288
+ # All other keys (including preferences) are treated as generic context
2178
2289
  for key, item in ctx_data.items():
2179
- 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']:
2180
2291
  self.shared_context[key] = item
2181
2292
  return # Only load the first .ctx file found
2182
2293
 
@@ -2356,146 +2467,98 @@ class Team:
2356
2467
  else:
2357
2468
  return None
2358
2469
 
2359
- def orchestrate(self, request):
2470
+ def orchestrate(self, request, max_iterations=3):
2360
2471
  """Orchestrate a request through the team"""
2361
- 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()
2362
2476
  if not forenpc:
2363
2477
  return {"error": "No forenpc available to coordinate the team"}
2364
-
2365
- log_entry(
2366
- self.name,
2367
- "orchestration_start",
2368
- {"request": request}
2369
- )
2370
-
2371
- result = forenpc.check_llm_command(request,
2372
- context=getattr(self, 'context', {}),
2373
- team = self,
2374
- )
2375
-
2376
- while True:
2377
- completion_prompt= ""
2378
- if isinstance(result, dict):
2379
- self.shared_context["execution_history"].append(result)
2380
-
2381
- if result.get("messages") and result.get("npc_name"):
2382
- if result["npc_name"] not in self.shared_context["npc_messages"]:
2383
- self.shared_context["npc_messages"][result["npc_name"]] = []
2384
- self.shared_context["npc_messages"][result["npc_name"]].extend(
2385
- result["messages"]
2386
- )
2387
-
2388
- completion_prompt += f"""Context:
2389
- User request '{request}', previous agent
2390
-
2391
- previous agent returned:
2392
- {result.get('output')}
2393
2478
 
2394
-
2395
- Instructions:
2479
+ print(colored(f"[orchestrate] Starting with forenpc={forenpc.name}, team={self.name}", "cyan"))
2480
+ print(colored(f"[orchestrate] Request: {request[:100]}...", "cyan"))
2396
2481
 
2397
- 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'}
2398
2484
 
2399
- """
2400
- if self.npcs is None or len(self.npcs) == 0:
2401
- completion_prompt += f"""
2402
- The team has no members, so the forenpc must handle the request alone.
2403
- """
2404
- else:
2405
- completion_prompt += f"""
2406
-
2407
- These are all the members of the team: {', '.join(self.npcs.keys())}
2408
-
2409
- Therefore, if you are trying to evaluate whether a request was fulfilled relevantly,
2410
- consider that requests are made to the forenpc: {forenpc.name}
2411
- and that the forenpc must pass those along to the other npcs.
2412
- """
2413
- completion_prompt += f"""
2414
-
2415
- Mainly concern yourself with ensuring there are no
2416
- glaring errors nor fundamental mishaps in the response.
2417
- Do not consider stylistic hiccups as the answers being
2418
- irrelevant. By providing responses back to for the user to
2419
- comment on, they can can more efficiently iterate and resolve any issues by
2420
- prompting more clearly.
2421
- natural language itself is very fuzzy so there will always be some level
2422
- of misunderstanding, but as long as the response is clearly relevant
2423
- to the input request and along the user's intended direction,
2424
- it is considered relevant.
2425
-
2426
-
2427
- If there is enough information to begin a fruitful conversation with the user,
2428
- please consider the request relevant so that we do not
2429
- arbritarily stall business logic which is more efficiently
2430
- determined by iterations than through unnecessary pedantry.
2431
-
2432
- It is more important to get a response to the user
2433
- than to account for all edge cases, so as long as the response more or less tackles the
2434
- initial problem to first order, consider it relevant.
2435
-
2436
- Return a JSON object with:
2437
- -'relevant' with boolean value
2438
- -'explanation' for irrelevance with quoted citations in your explanation noting why it is irrelevant to user input must be a single string.
2439
- Return only the JSON object."""
2440
-
2441
- completion_check = npy.llm_funcs.get_llm_response(
2442
- completion_prompt,
2443
- model=forenpc.model,
2444
- provider=forenpc.provider,
2445
- api_key=forenpc.api_key,
2446
- api_url=forenpc.api_url,
2447
- npc=forenpc,
2448
- 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,
2449
2491
  )
2450
-
2451
- if isinstance(completion_check.get("response"), dict):
2452
- complete = completion_check["response"].get("relevant", False)
2453
- explanation = completion_check["response"].get("explanation", "")
2454
- else:
2455
- complete = False
2456
- explanation = "Could not determine completion status"
2457
-
2458
- if complete:
2459
- debrief = npy.llm_funcs.get_llm_response(
2460
- f"""Context:
2461
- Original request: {request}
2462
- Execution history: {self.shared_context['execution_history']}
2463
-
2464
- Instructions:
2465
- Provide summary of actions taken and recommendations.
2466
- Return a JSON object with:
2467
- - 'summary': Overview of what was accomplished
2468
- - 'recommendations': Suggested next steps
2469
- Return only the JSON object.""",
2470
- model=forenpc.model,
2471
- provider=forenpc.provider,
2472
- api_key=forenpc.api_key,
2473
- api_url=forenpc.api_url,
2474
- npc=forenpc,
2475
- format="json"
2476
- )
2477
-
2478
- return {
2479
- "debrief": debrief.get("response"),
2480
- "output": result.get("output"),
2481
- "execution_history": self.shared_context["execution_history"],
2482
- }
2483
- else:
2484
- updated_request = (
2485
- request
2486
- + "\n\nThe request has not yet been fully completed. "
2487
- + explanation
2488
- + "\nPlease address only the remaining parts of the request."
2489
- )
2490
- print('updating request', updated_request)
2491
-
2492
- result = forenpc.check_llm_command(
2493
- updated_request,
2494
- context=getattr(self, 'context', {}),
2495
- stream = False,
2496
- team = self
2497
-
2498
- )
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
+ }
2499
2562
 
2500
2563
  def to_dict(self):
2501
2564
  """Convert team to dictionary representation"""