npcpy 1.2.32__py3-none-any.whl → 1.2.34__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
@@ -11,10 +11,11 @@ import random
11
11
  from datetime import datetime
12
12
  import hashlib
13
13
  import pathlib
14
+ import sys
14
15
  import fnmatch
15
16
  import subprocess
16
- from typing import Any, Dict, List, Optional, Union
17
- from jinja2 import Environment, FileSystemLoader, Template, Undefined
17
+ from typing import Any, Dict, List, Optional, Union, Callable, Tuple
18
+ from jinja2 import Environment, FileSystemLoader, Template, Undefined, DictLoader
18
19
  from sqlalchemy import create_engine, text
19
20
  import npcpy as npy
20
21
  from npcpy.llm_funcs import DEFAULT_ACTION_SPACE
@@ -34,6 +35,12 @@ class SilentUndefined(Undefined):
34
35
 
35
36
  import math
36
37
  from PIL import Image
38
+ from jinja2 import Environment, ChainableUndefined
39
+
40
+ class PreserveUndefined(ChainableUndefined):
41
+ """Undefined that preserves the original {{ variable }} syntax"""
42
+ def __str__(self):
43
+ return f"{{{{ {self._undefined_name} }}}}"
37
44
 
38
45
 
39
46
  def agent_pass_handler(command, extracted_data, **kwargs):
@@ -231,13 +238,21 @@ def write_yaml_file(file_path, data):
231
238
 
232
239
  class Jinx:
233
240
  '''
241
+ Jinx represents a workflow template with Jinja-rendered steps.
242
+
243
+ Loads YAML definition containing:
244
+ - jinx_name: identifier
245
+ - inputs: list of input parameters
246
+ - description: what the jinx does
247
+ - npc: optional NPC to execute with
248
+ - steps: list of step definitions with code
234
249
 
235
- Jinx is a class that provides methods for rendering jinja templates to execute
236
- natural language commands within the NPC ecosystem, python, and eventually
237
- other code languages.
250
+ Execution:
251
+ - Renders Jinja templates in step code with input values
252
+ - Executes resulting Python code
253
+ - Returns context with outputs
238
254
  '''
239
255
  def __init__(self, jinx_data=None, jinx_path=None):
240
- """Initialize a jinx from data or file path"""
241
256
  if jinx_path:
242
257
  self._load_from_file(jinx_path)
243
258
  elif jinx_data:
@@ -245,15 +260,16 @@ class Jinx:
245
260
  else:
246
261
  raise ValueError("Either jinx_data or jinx_path must be provided")
247
262
 
263
+ self._raw_steps = list(self.steps)
264
+ self.steps = []
265
+
248
266
  def _load_from_file(self, path):
249
- """Load jinx from file"""
250
267
  jinx_data = load_yaml_file(path)
251
268
  if not jinx_data:
252
269
  raise ValueError(f"Failed to load jinx from {path}")
253
270
  self._load_from_data(jinx_data)
254
271
 
255
272
  def _load_from_data(self, jinx_data):
256
- """Load jinx from data dictionary"""
257
273
  if not jinx_data or not isinstance(jinx_data, dict):
258
274
  raise ValueError("Invalid jinx data provided")
259
275
 
@@ -263,62 +279,131 @@ class Jinx:
263
279
  self.jinx_name = jinx_data.get("jinx_name")
264
280
  self.inputs = jinx_data.get("inputs", [])
265
281
  self.description = jinx_data.get("description", "")
266
- self.steps = self._parse_steps(jinx_data.get("steps", []))
267
- def _parse_steps(self, steps):
268
- """Parse steps from jinx definition"""
269
- parsed_steps = []
270
- for i, step in enumerate(steps):
271
- if isinstance(step, dict):
272
- parsed_step = {
273
- "name": step.get("name", f"step_{i}"),
274
- "engine": step.get("engine", "natural"),
275
- "code": step.get("code", "")
282
+ self.npc = jinx_data.get("npc")
283
+ self.steps = jinx_data.get("steps", [])
284
+
285
+ def render_first_pass(
286
+ self,
287
+ jinja_env_for_macros: Environment,
288
+ all_jinx_callables: Dict[str, Callable]
289
+ ):
290
+ """
291
+ Performs the first-pass Jinja rendering on the Jinx's raw steps.
292
+ This expands nested Jinx calls (e.g., {{ sh(...) }} or
293
+ engine: jinx_name) but preserves runtime variables
294
+ (e.g., {{ command_var }}).
295
+
296
+ Args:
297
+ jinja_env_for_macros: The Jinja Environment configured with
298
+ Jinx callables in its globals.
299
+ all_jinx_callables: A dictionary of Jinx names to their
300
+ callable functions (from create_jinx_callable).
301
+ """
302
+ rendered_steps_output = []
303
+
304
+ for raw_step in self._raw_steps:
305
+ if not isinstance(raw_step, dict):
306
+ rendered_steps_output.append(raw_step)
307
+ continue
308
+
309
+ engine_name = raw_step.get('engine')
310
+
311
+ # If this step references another jinx via engine, expand it
312
+ if engine_name and engine_name in all_jinx_callables:
313
+ step_name = raw_step.get('name', f'call_{engine_name}')
314
+ jinx_args = {
315
+ k: v for k, v in raw_step.items()
316
+ if k not in ['engine', 'name']
276
317
  }
277
- if "mode" in step:
278
- parsed_step["mode"] = step["mode"]
279
- parsed_steps.append(parsed_step)
318
+
319
+ jinx_callable = all_jinx_callables[engine_name]
320
+ try:
321
+ expanded_yaml_string = jinx_callable(**jinx_args)
322
+ expanded_steps = yaml.safe_load(expanded_yaml_string)
323
+
324
+ if isinstance(expanded_steps, list):
325
+ rendered_steps_output.extend(expanded_steps)
326
+
327
+ elif expanded_steps is not None:
328
+ rendered_steps_output.append(expanded_steps)
329
+
330
+ except Exception as e:
331
+ print(
332
+ f"Warning: Error expanding Jinx '{engine_name}' "
333
+ f"within Jinx '{self.jinx_name}' "
334
+ f"(declarative): {e}"
335
+ )
336
+ rendered_steps_output.append(raw_step)
337
+ # Skip rendering for python/bash engine steps - preserve runtime variables
338
+ elif raw_step.get('engine') in ['python', 'bash']:
339
+ rendered_steps_output.append(raw_step)
280
340
  else:
281
- raise ValueError(f"Invalid step format: {step}")
282
- return parsed_steps
341
+ # For other steps, do first-pass rendering (inline macro expansion)
342
+ processed_step = {}
343
+ for key, value in raw_step.items():
344
+ if isinstance(value, str):
345
+ try:
346
+ template = jinja_env_for_macros.from_string(
347
+ value
348
+ )
349
+ rendered_value = template.render({})
350
+
351
+ try:
352
+ loaded_value = yaml.safe_load(
353
+ rendered_value
354
+ )
355
+ processed_step[key] = loaded_value
356
+ except yaml.YAMLError:
357
+ processed_step[key] = rendered_value
358
+ except Exception as e:
359
+ print(
360
+ f"Warning: Error during first-pass "
361
+ f"rendering of Jinx '{self.jinx_name}' "
362
+ f"step field '{key}' (inline macro): {e}"
363
+ )
364
+ processed_step[key] = value
365
+ else:
366
+ processed_step[key] = value
367
+ rendered_steps_output.append(processed_step)
368
+
369
+ self.steps = rendered_steps_output
283
370
 
284
371
  def execute(self,
285
372
  input_values: Dict[str, Any],
286
- jinxs_dict: Dict[str, 'Jinx'],
287
- jinja_env: Optional[Environment] = None,
288
373
  npc: Optional[Any] = None,
289
374
  messages: Optional[List[Dict[str, str]]] = None,
290
- **kwargs: Any):
291
- """
292
- Execute the jinx with given inputs.
293
- **kwargs can be used to pass 'extra_globals' for the python engine.
294
- """
375
+ extra_globals: Optional[Dict[str, Any]] = None,
376
+ jinja_env: Optional[Environment] = None):
377
+
295
378
  if jinja_env is None:
296
- from jinja2 import DictLoader
297
379
  jinja_env = Environment(
298
380
  loader=DictLoader({}),
299
381
  undefined=SilentUndefined,
300
382
  )
301
383
 
302
- context = (npc.shared_context.copy() if npc and hasattr(npc, 'shared_context') else {})
384
+ active_npc = self.npc if self.npc else npc
385
+
386
+ context = (
387
+ active_npc.shared_context.copy()
388
+ if active_npc and hasattr(active_npc, 'shared_context')
389
+ else {}
390
+ )
303
391
  context.update(input_values)
304
392
  context.update({
305
- "jinxs": jinxs_dict,
306
393
  "llm_response": None,
307
394
  "output": None,
308
395
  "messages": messages,
396
+ "npc": active_npc
309
397
  })
310
-
311
- # This is the key change: Extract 'extra_globals' from kwargs
312
- extra_globals = kwargs.get('extra_globals')
313
398
 
314
399
  for i, step in enumerate(self.steps):
315
400
  context = self._execute_step(
316
401
  step,
317
402
  context,
318
403
  jinja_env,
319
- npc=npc,
404
+ npc=active_npc,
320
405
  messages=messages,
321
- extra_globals=extra_globals # Pass it down to the step executor
406
+ extra_globals=extra_globals
322
407
  )
323
408
 
324
409
  return context
@@ -330,141 +415,150 @@ class Jinx:
330
415
  npc: Optional[Any] = None,
331
416
  messages: Optional[List[Dict[str, str]]] = None,
332
417
  extra_globals: Optional[Dict[str, Any]] = None):
333
- """
334
- Execute a single step of the jinx.
335
- """
336
- engine = step.get("engine", "natural")
337
- code = step.get("code", "")
338
- step_name = step.get("name", "unnamed_step")
339
- mode = step.get("mode", "chat")
418
+
419
+ def _log_debug(msg):
420
+ log_file_path = os.path.expanduser("~/jinx_debug_log.txt")
421
+ with open(log_file_path, "a") as f:
422
+ f.write(f"[{datetime.now().isoformat()}] {msg}\n")
340
423
 
424
+ code_content = step.get("code", "")
425
+ step_name = step.get("name", "unnamed_step")
426
+ step_npc = step.get("npc")
427
+
428
+ active_npc = step_npc if step_npc else npc
429
+
341
430
  try:
342
- template = jinja_env.from_string(code)
431
+ template = jinja_env.from_string(code_content)
343
432
  rendered_code = template.render(**context)
344
-
345
- engine_template = jinja_env.from_string(engine)
346
- rendered_engine = engine_template.render(**context)
433
+ except Exception as e:
434
+ _log_debug(
435
+ f"Error rendering template for step {step_name} "
436
+ f"(second pass): {e}"
437
+ )
438
+ rendered_code = code_content
439
+
440
+ _log_debug(f"rendered jinx code: {rendered_code}")
441
+ _log_debug(
442
+ f"DEBUG: Before exec - rendered_code: {rendered_code}"
443
+ )
444
+ _log_debug(
445
+ f"DEBUG: Before exec - context['output'] before step: "
446
+ f"{context.get('output')}"
447
+ )
448
+
449
+ exec_globals = {
450
+ "__builtins__": __builtins__,
451
+ "npc": active_npc,
452
+ "context": context,
453
+ "pd": pd,
454
+ "plt": plt,
455
+ "sys": sys,
456
+ "subprocess": subprocess,
457
+ "np": np,
458
+ "os": os,
459
+ 're': re,
460
+ "json": json,
461
+ "Path": pathlib.Path,
462
+ "fnmatch": fnmatch,
463
+ "pathlib": pathlib,
464
+ "subprocess": subprocess,
465
+ "get_llm_response": npy.llm_funcs.get_llm_response,
466
+ "CommandHistory": CommandHistory,
467
+ }
347
468
 
469
+ if extra_globals:
470
+ exec_globals.update(extra_globals)
471
+
472
+ exec_locals = {}
473
+
474
+ try:
475
+ exec(rendered_code, exec_globals, exec_locals)
348
476
  except Exception as e:
349
- print(f"Error rendering templates for step {step_name}: {e}")
350
- rendered_code = code
351
- rendered_engine = engine
352
-
353
- if rendered_engine == "natural":
354
- if rendered_code.strip():
355
- if mode == "agent":
356
- response = npc.get_llm_response(
357
- rendered_code,
358
- context=context,
359
- messages=messages,
360
- auto_process_tool_calls=True,
361
- use_core_tools=True
362
- )
363
- else:
364
- response = npc.get_llm_response(
365
- rendered_code,
366
- context=context,
367
- messages=messages,
368
- )
369
-
370
- response_text = response.get("response", "")
371
- context['output'] = response_text
372
- context["llm_response"] = response_text
373
- context["results"] = response_text
374
- context[step_name] = response_text
375
- context['messages'] = response.get('messages')
376
-
377
- elif rendered_engine == "python":
378
- # Base globals available to all python jinxes, defined within the library (npcpy)
379
- exec_globals = {
380
- "__builtins__": __builtins__,
381
- "npc": npc,
382
- "context": context,
383
- "pd": pd,
384
- "plt": plt,
385
- "np": np,
386
- "os": os,
387
- 're': re,
388
- "json": json,
389
- "Path": pathlib.Path,
390
- "fnmatch": fnmatch,
391
- "pathlib": pathlib,
392
- "subprocess": subprocess,
393
- "get_llm_response": npy.llm_funcs.get_llm_response,
394
- "CommandHistory": CommandHistory, # This is fine, it's part of npcpy
395
- }
396
-
397
- # This is the fix: Update the globals with the dictionary passed in from the application (npcsh)
398
- if extra_globals:
399
- exec_globals.update(extra_globals)
400
-
401
- exec_locals = {}
402
- try:
403
- exec(rendered_code, exec_globals, exec_locals)
404
- except Exception as e:
405
- # Provide a clear error message in the output if execution fails
406
- error_msg = f"Error executing jinx python code: {type(e).__name__}: {e}"
407
- context['output'] = error_msg
408
- return context
477
+ error_msg = (
478
+ f"Error executing step {step_name}: "
479
+ f"{type(e).__name__}: {e}"
480
+ )
481
+ context['output'] = error_msg
482
+ _log_debug(error_msg)
483
+ return context
484
+
485
+ _log_debug(f"DEBUG: After exec - exec_locals: {exec_locals}")
486
+ _log_debug(
487
+ f"DEBUG: After exec - 'output' in exec_locals: "
488
+ f"{'output' in exec_locals}"
489
+ )
409
490
 
410
- context.update(exec_locals)
411
-
412
- if "output" in exec_locals:
413
- outp = exec_locals["output"]
414
- context["output"] = outp
415
- context[step_name] = outp
416
- if messages is not None:
417
- messages.append({'role':'assistant',
418
- 'content': f'Jinx executed with following output: {outp}'})
419
- context['messages'] = messages
420
-
421
- else:
422
- context[step_name] = {"error": f"Unsupported engine: {rendered_engine}"}
423
-
491
+ context.update(exec_locals)
492
+
493
+ _log_debug(
494
+ f"DEBUG: After context.update(exec_locals) - "
495
+ f"context['output']: {context.get('output')}"
496
+ )
497
+ _log_debug(f"context after jinx ex: {context}")
498
+
499
+ if "output" in exec_locals:
500
+ outp = exec_locals["output"]
501
+ context["output"] = outp
502
+ context[step_name] = outp
503
+
504
+ _log_debug(
505
+ f"DEBUG: Inside 'output' in exec_locals block - "
506
+ f"context['output']: {context.get('output')}"
507
+ )
508
+
509
+ if messages is not None:
510
+ messages.append({
511
+ 'role':'assistant',
512
+ 'content': (
513
+ f'Jinx {self.jinx_name} step {step_name} '
514
+ f'executed: {outp}'
515
+ )
516
+ })
517
+ context['messages'] = messages
518
+
424
519
  return context
425
- def to_dict(self):
426
- """Convert to dictionary representation"""
427
- steps_list = []
428
- for i, step in enumerate(self.steps):
429
- step_dict = {
430
- "name": step.get("name", f"step_{i}"),
431
- "engine": step.get("engine"),
432
- "code": step.get("code")
433
- }
434
- if "mode" in step:
435
- step_dict["mode"] = step["mode"]
436
- steps_list.append(step_dict)
437
520
 
438
- return {
521
+
522
+ def to_dict(self):
523
+ result = {
439
524
  "jinx_name": self.jinx_name,
440
525
  "description": self.description,
441
526
  "inputs": self.inputs,
442
- "steps": steps_list
527
+ "steps": self._raw_steps
443
528
  }
529
+
530
+ if self.npc:
531
+ result["npc"] = self.npc
532
+
533
+ return result
534
+
444
535
  def save(self, directory):
445
- """Save jinx to file"""
446
536
  jinx_path = os.path.join(directory, f"{self.jinx_name}.jinx")
447
537
  ensure_dirs_exist(os.path.dirname(jinx_path))
448
538
  return write_yaml_file(jinx_path, self.to_dict())
449
539
 
450
540
  @classmethod
451
541
  def from_mcp(cls, mcp_tool):
452
- """Convert an MCP tool to NPC jinx format"""
453
-
454
542
  try:
455
543
  import inspect
456
544
 
457
-
458
545
  doc = mcp_tool.__doc__ or ""
459
546
  name = mcp_tool.__name__
460
547
  signature = inspect.signature(mcp_tool)
461
548
 
462
-
463
549
  inputs = []
464
550
  for param_name, param in signature.parameters.items():
465
- if param_name != 'self':
466
- param_type = param.annotation if param.annotation != inspect.Parameter.empty else None
467
- param_default = None if param.default == inspect.Parameter.empty else param.default
551
+ if param_name != 'self':
552
+ param_type = (
553
+ param.annotation
554
+ if param.annotation != inspect.Parameter.empty
555
+ else None
556
+ )
557
+ param_default = (
558
+ None
559
+ if param.default == inspect.Parameter.empty
560
+ else param.default
561
+ )
468
562
 
469
563
  inputs.append({
470
564
  "name": param_name,
@@ -472,7 +566,6 @@ class Jinx:
472
566
  "default": param_default
473
567
  })
474
568
 
475
-
476
569
  jinx_data = {
477
570
  "jinx_name": name,
478
571
  "description": doc.strip(),
@@ -480,12 +573,13 @@ class Jinx:
480
573
  "steps": [
481
574
  {
482
575
  "name": "mcp_function_call",
483
- "engine": "python",
484
576
  "code": f"""
485
-
486
577
  import {mcp_tool.__module__}
487
578
  output = {mcp_tool.__module__}.{name}(
488
- {', '.join([f'{inp["name"]}=context.get("{inp["name"]}")' for inp in inputs])}
579
+ {', '.join([
580
+ f'{inp["name"]}=context.get("{inp["name"]}")'
581
+ for inp in inputs
582
+ ])}
489
583
  )
490
584
  """
491
585
  }
@@ -495,7 +589,7 @@ output = {mcp_tool.__module__}.{name}(
495
589
  return cls(jinx_data=jinx_data)
496
590
 
497
591
  except:
498
- pass
592
+ pass
499
593
 
500
594
  def load_jinxs_from_directory(directory):
501
595
  """Load all jinxs from a directory recursively"""
@@ -687,8 +781,8 @@ class NPC:
687
781
  name: str = None,
688
782
  primary_directive: str = None,
689
783
  plain_system_message: bool = False,
690
- team = None,
691
- jinxs: list = None,
784
+ team = None, # Can be None initially
785
+ jinxs: list = None, # Explicit jinxs for this NPC
692
786
  tools: list = None,
693
787
  model: str = None,
694
788
  provider: str = None,
@@ -696,7 +790,7 @@ class NPC:
696
790
  api_key: str = None,
697
791
  db_conn=None,
698
792
  use_global_jinxs=False,
699
- memory = False,
793
+ memory = False,
700
794
  **kwargs
701
795
  ):
702
796
  """
@@ -734,7 +828,9 @@ class NPC:
734
828
  self.jinxs_directory = None
735
829
  self.npc_directory = None
736
830
 
737
- self.team = team
831
+ self.team = team # Store the team reference (can be None)
832
+ self.jinxs_spec = jinxs or "*" # Store the jinx specification for later loading
833
+
738
834
  if tools is not None:
739
835
  tools_schema, tool_map = auto_tools(tools)
740
836
  self.tools = tools_schema
@@ -755,6 +851,7 @@ class NPC:
755
851
  if self.jinxs_directory:
756
852
  dirs.append(self.jinxs_directory)
757
853
 
854
+ # This jinja_env is for the *second pass* (runtime variable resolution in Jinx.execute)
758
855
  self.jinja_env = Environment(
759
856
  loader=FileSystemLoader([
760
857
  os.path.expanduser(d) for d in dirs
@@ -764,7 +861,6 @@ class NPC:
764
861
 
765
862
  self.db_conn = db_conn
766
863
 
767
- # these 4 get overwritten if the db conn
768
864
  self.command_history = None
769
865
  self.kg_data = None
770
866
  self.tables = None
@@ -777,9 +873,24 @@ class NPC:
777
873
  self.kg_data = self._load_npc_kg()
778
874
  self.memory = self.get_memory_context()
779
875
 
780
-
781
-
782
- self.jinxs = self._load_npc_jinxs(jinxs or "*")
876
+ self.jinxs_dict = {} # Initialize empty, will be populated by initialize_jinxs
877
+ # If jinxs are explicitly provided *to the NPC* during its standalone creation, load them.
878
+ # This is for NPCs created *outside* a team context initially.
879
+ if jinxs and jinxs != "*":
880
+ for jinx_item in jinxs:
881
+ if isinstance(jinx_item, Jinx):
882
+ self.jinxs_dict[jinx_item.jinx_name] = jinx_item
883
+ elif isinstance(jinx_item, dict):
884
+ jinx_obj = Jinx(jinx_data=jinx_item)
885
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
886
+ elif isinstance(jinx_item, str):
887
+ # Try to load from NPC's own directory first
888
+ jinx_path = find_file_path(jinx_item, [self.npc_jinxs_directory], suffix=".jinx")
889
+ if jinx_path:
890
+ jinx_obj = Jinx(jinx_path=jinx_path)
891
+ self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
892
+ else:
893
+ print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
783
894
 
784
895
  self.shared_context = {
785
896
  "dataframes": {},
@@ -794,6 +905,71 @@ class NPC:
794
905
  if db_conn is not None:
795
906
  init_db_tables()
796
907
 
908
+ def initialize_jinxs(self, team_raw_jinxs: Optional[List['Jinx']] = None):
909
+ """
910
+ Loads and performs first-pass Jinja rendering for NPC-specific jinxs,
911
+ now that the NPC's team context is fully established.
912
+ """
913
+ npc_jinxs_raw_list = []
914
+
915
+ # If jinxs_spec is "*", inherit all from team
916
+ if self.jinxs_spec == "*":
917
+ if self.team and hasattr(self.team, 'jinxs_dict') and self.team.jinxs_dict:
918
+ self.jinxs_dict.update(self.team.jinxs_dict)
919
+ else: # If specific jinxs are requested, try to get them from team
920
+ for jinx_name in self.jinxs_spec:
921
+ if self.team and jinx_name in self.team.jinxs_dict:
922
+ self.jinxs_dict[jinx_name] = self.team.jinxs_dict[jinx_name]
923
+
924
+ # Load NPC's own jinxs (if not already covered by team or if specific ones are requested)
925
+ if hasattr(self, 'npc_jinxs_directory') and self.npc_jinxs_directory and os.path.exists(self.npc_jinxs_directory):
926
+ for jinx_obj in load_jinxs_from_directory(self.npc_jinxs_directory):
927
+ if jinx_obj.jinx_name not in self.jinxs_dict: # Only add if not already added from team
928
+ npc_jinxs_raw_list.append(jinx_obj)
929
+
930
+ # If there are raw NPC jinxs to render or team_raw_jinxs available
931
+ if npc_jinxs_raw_list or team_raw_jinxs:
932
+ all_available_raw_jinxs = list(team_raw_jinxs or [])
933
+ all_available_raw_jinxs.extend(npc_jinxs_raw_list)
934
+
935
+ combined_raw_jinxs_dict = {j.jinx_name: j for j in all_available_raw_jinxs}
936
+
937
+ npc_first_pass_jinja_env = Environment(undefined=SilentUndefined)
938
+
939
+ jinx_macro_globals = {}
940
+ for raw_jinx in combined_raw_jinxs_dict.values():
941
+ def create_jinx_callable(jinx_obj_in_closure):
942
+ def callable_jinx(**kwargs):
943
+ temp_jinja_env = Environment(undefined=SilentUndefined)
944
+ rendered_target_steps = []
945
+ for target_step in jinx_obj_in_closure._raw_steps:
946
+ temp_rendered_step = {}
947
+ for k, v in target_step.items():
948
+ if isinstance(v, str):
949
+ try:
950
+ temp_rendered_step[k] = temp_jinja_env.from_string(v).render(**kwargs)
951
+ except Exception as e:
952
+ print(f"Warning: Error in Jinx macro '{jinx_obj_in_closure.jinx_name}' rendering step field '{k}' (NPC first pass): {e}")
953
+ temp_rendered_step[k] = v
954
+ else:
955
+ temp_rendered_step[k] = v
956
+ rendered_target_steps.append(temp_rendered_step)
957
+ return yaml.dump(rendered_target_steps, default_flow_style=False)
958
+ return callable_jinx
959
+
960
+ jinx_macro_globals[raw_jinx.jinx_name] = create_jinx_callable(raw_jinx)
961
+
962
+ npc_first_pass_jinja_env.globals.update(jinx_macro_globals)
963
+
964
+ for raw_npc_jinx in npc_jinxs_raw_list:
965
+ try:
966
+ raw_npc_jinx.render_first_pass(npc_first_pass_jinja_env, jinx_macro_globals)
967
+ self.jinxs_dict[raw_npc_jinx.jinx_name] = raw_npc_jinx
968
+ except Exception as e:
969
+ print(f"Error performing first-pass rendering for NPC Jinx '{raw_npc_jinx.jinx_name}': {e}")
970
+
971
+ print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs.")
972
+
797
973
  def _load_npc_kg(self):
798
974
  """Load knowledge graph data for this NPC from database"""
799
975
  if not self.command_history:
@@ -1093,45 +1269,6 @@ class NPC:
1093
1269
  self.tables = None
1094
1270
  self.db_type = None
1095
1271
 
1096
- def _load_npc_jinxs(self, jinxs):
1097
- """Load and process NPC-specific jinxs"""
1098
- npc_jinxs = []
1099
-
1100
- if jinxs == "*":
1101
- if self.team and hasattr(self.team, 'jinxs_dict'):
1102
- for jinx in self.team.jinxs_dict.values():
1103
- npc_jinxs.append(jinx)
1104
- elif self.use_global_jinxs or (hasattr(self, 'jinxs_directory') and self.jinxs_directory):
1105
- jinxs_dir = self.jinxs_directory or os.path.expanduser('~/.npcsh/npc_team/jinxs/')
1106
- if os.path.exists(jinxs_dir):
1107
- npc_jinxs.extend(load_jinxs_from_directory(jinxs_dir))
1108
-
1109
- self.jinxs_dict = {jinx.jinx_name: jinx for jinx in npc_jinxs}
1110
- return npc_jinxs
1111
-
1112
- for jinx in jinxs:
1113
- if isinstance(jinx, Jinx):
1114
- npc_jinxs.append(jinx)
1115
- elif isinstance(jinx, dict):
1116
- npc_jinxs.append(Jinx(jinx_data=jinx))
1117
- elif isinstance(jinx, str):
1118
- jinx_path = None
1119
- jinx_name = jinx
1120
- if not jinx_name.endswith(".jinx"):
1121
- jinx_name += ".jinx"
1122
-
1123
- if hasattr(self, 'jinxs_directory') and self.jinxs_directory and os.path.exists(self.jinxs_directory):
1124
- candidate_path = os.path.join(self.jinxs_directory, jinx_name)
1125
- if os.path.exists(candidate_path):
1126
- jinx_path = candidate_path
1127
-
1128
- if jinx_path:
1129
- jinx_obj = Jinx(jinx_path=jinx_path)
1130
- npc_jinxs.append(jinx_obj)
1131
-
1132
- self.jinxs_dict = {jinx.jinx_name: jinx for jinx in npc_jinxs}
1133
- print(npc_jinxs)
1134
- return npc_jinxs
1135
1272
  def get_llm_response(self,
1136
1273
  request,
1137
1274
  jinxs=None,
@@ -1225,7 +1362,7 @@ class NPC:
1225
1362
  content = result.get('content', '')[:200] + ('...' if len(result.get('content', '')) > 200 else '')
1226
1363
  formatted_results.append(f"[{timestamp}] {content}")
1227
1364
 
1228
- return f"Found {len(results)} conversations matching '{query}':\n" + "\n".join(formatted_results)
1365
+ return f"Found {len(results)} conversations matching '{query}'s:\n" + "\n".join(formatted_results)
1229
1366
 
1230
1367
  def search_my_memories(self, query: str, limit: int = 10) -> str:
1231
1368
  """Search through this NPC's knowledge graph memories for relevant facts and concepts"""
@@ -1298,113 +1435,6 @@ class NPC:
1298
1435
  response = self.get_llm_response(thinking_prompt, tool_choice = False)
1299
1436
  return response.get('response', 'Unable to process thinking request')
1300
1437
 
1301
- def write_code(self, task_description: str, language: str = "python", show=True) -> str:
1302
- """Generate and execute code for a specific task, returning the result"""
1303
- if language.lower() != "python":
1304
-
1305
- code_prompt = f"""Write {language} code for the following task:
1306
- {task_description}
1307
-
1308
- Provide clean, working code with brief explanations for key parts:"""
1309
-
1310
- response = self.get_llm_response(code_prompt, tool_choice=False )
1311
- return response.get('response', 'Unable to generate code')
1312
-
1313
-
1314
- code_prompt = f"""Write Python code for the following task:
1315
- {task_description}
1316
-
1317
- Requirements:
1318
- - Provide executable Python code
1319
- - Store the final result in a variable called 'output'
1320
- - Include any necessary imports
1321
- - Handle errors gracefully
1322
- - The code should be ready to execute without modification
1323
-
1324
- Example format:
1325
- ```python
1326
- import pandas as pd
1327
- # Your code here
1328
- result = some_calculation()
1329
- output = f"Task completed successfully: {{result}}"
1330
- """
1331
- response = self.get_llm_response(code_prompt, tool_choice= False)
1332
- generated_code = response.get('response', '')
1333
-
1334
-
1335
- if '```python' in generated_code:
1336
- code_lines = generated_code.split('\n')
1337
- start_idx = None
1338
- end_idx = None
1339
-
1340
- for i, line in enumerate(code_lines):
1341
- if '```python' in line:
1342
- start_idx = i + 1
1343
- elif '```' in line and start_idx is not None:
1344
- end_idx = i
1345
- break
1346
-
1347
- if start_idx is not None:
1348
- if end_idx is not None:
1349
- generated_code = '\n'.join(code_lines[start_idx:end_idx])
1350
- else:
1351
- generated_code = '\n'.join(code_lines[start_idx:])
1352
-
1353
- try:
1354
-
1355
- exec_globals = {
1356
- "__builtins__": __builtins__,
1357
- "npc": self,
1358
- "context": self.shared_context,
1359
- "pd": pd,
1360
- "plt": plt,
1361
- "np": np,
1362
- "os": os,
1363
- "re": re,
1364
- "json": json,
1365
- "Path": pathlib.Path,
1366
- "fnmatch": fnmatch,
1367
- "pathlib": pathlib,
1368
- "subprocess": subprocess,
1369
- "datetime": datetime,
1370
- "hashlib": hashlib,
1371
- "sqlite3": sqlite3,
1372
- "yaml": yaml,
1373
- "random": random,
1374
- "math": math,
1375
- }
1376
-
1377
- exec_locals = {}
1378
-
1379
-
1380
- exec(generated_code, exec_globals, exec_locals)
1381
-
1382
- if show:
1383
- print('Executing code', generated_code)
1384
-
1385
-
1386
- if "output" in exec_locals:
1387
- result = exec_locals["output"]
1388
-
1389
- self.shared_context.update({k: v for k, v in exec_locals.items()
1390
- if not k.startswith('_') and not callable(v)})
1391
- return f"Code executed successfully. Result: {result}"
1392
- else:
1393
-
1394
- meaningful_vars = {k: v for k, v in exec_locals.items()
1395
- if not k.startswith('_') and not callable(v)}
1396
-
1397
- self.shared_context.update(meaningful_vars)
1398
-
1399
- if meaningful_vars:
1400
- last_var = list(meaningful_vars.items())[-1]
1401
- return f"Code executed successfully. Last result: {last_var[0]} = {last_var[1]}"
1402
- else:
1403
- return "Code executed successfully (no explicit output generated)"
1404
-
1405
- except Exception as e:
1406
- error_msg = f"Error executing code: {str(e)}\n\nGenerated code was:\n{generated_code}"
1407
- return error_msg
1408
1438
 
1409
1439
 
1410
1440
 
@@ -1621,23 +1651,29 @@ class NPC:
1621
1651
  "compressed_state": self.compress_planning_state(planning_state),
1622
1652
  "summary": f"Completed {len(planning_state['successes'])} tasks for goal: {user_goal}"
1623
1653
  }
1624
-
1625
- def execute_jinx(self, jinx_name, inputs, conversation_id=None, message_id=None, team_name=None):
1626
- """Execute a jinx by name"""
1627
1654
 
1655
+ def execute_jinx(
1656
+ self,
1657
+ jinx_name,
1658
+ inputs,
1659
+ conversation_id=None,
1660
+ message_id=None,
1661
+ team_name=None,
1662
+ extra_globals=None
1663
+ ):
1628
1664
  if jinx_name in self.jinxs_dict:
1629
1665
  jinx = self.jinxs_dict[jinx_name]
1630
- elif jinx_name in self.jinxs_dict:
1631
- jinx = self.jinxs_dict[jinx_name]
1632
1666
  else:
1633
1667
  return {"error": f"jinx '{jinx_name}' not found"}
1634
1668
 
1635
1669
  result = jinx.execute(
1636
1670
  input_values=inputs,
1637
- context=self.shared_context,
1638
- jinja_env=self.jinja_env,
1639
- npc=self
1671
+ npc=self,
1672
+ # messages=messages, # messages should be passed from the calling context if available
1673
+ extra_globals=extra_globals,
1674
+ jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
1640
1675
  )
1676
+
1641
1677
  if self.db_conn is not None:
1642
1678
  self.db_conn.add_jinx_call(
1643
1679
  triggering_message_id=message_id,
@@ -1652,7 +1688,6 @@ class NPC:
1652
1688
  team_name=team_name,
1653
1689
  )
1654
1690
  return result
1655
-
1656
1691
  def check_llm_command(self,
1657
1692
  command,
1658
1693
  messages=None,
@@ -1722,8 +1757,8 @@ class NPC:
1722
1757
  def to_dict(self):
1723
1758
  """Convert NPC to dictionary representation"""
1724
1759
  jinx_rep = []
1725
- if self.jinxs is not None:
1726
- jinx_rep = [ jinx.to_dict() if isinstance(jinx, Jinx) else jinx for jinx in self.jinxs]
1760
+ if self.jinxs_dict: # Use jinxs_dict which stores the rendered Jinx objects
1761
+ jinx_rep = [ jinx.to_dict() for jinx in self.jinxs_dict.values()]
1727
1762
  return {
1728
1763
  "name": self.name,
1729
1764
  "primary_directive": self.primary_directive,
@@ -1731,7 +1766,7 @@ class NPC:
1731
1766
  "provider": self.provider,
1732
1767
  "api_url": self.api_url,
1733
1768
  "api_key": self.api_key,
1734
- "jinxs": jinx_rep,
1769
+ "jinxs": self.jinxs_spec, # Save the original spec, not the rendered objects
1735
1770
  "use_global_jinxs": self.use_global_jinxs
1736
1771
  }
1737
1772
 
@@ -1748,10 +1783,10 @@ class NPC:
1748
1783
  def __str__(self):
1749
1784
  """String representation of NPC"""
1750
1785
  str_rep = f"NPC: {self.name}\nDirective: {self.primary_directive}\nModel: {self.model}\nProvider: {self.provider}\nAPI URL: {self.api_url}\n"
1751
- if self.jinxs:
1786
+ if self.jinxs_dict:
1752
1787
  str_rep += "Jinxs:\n"
1753
- for jinx in self.jinxs:
1754
- str_rep += f" - {jinx.jinx_name}\n"
1788
+ for jinx_name in self.jinxs_dict.keys():
1789
+ str_rep += f" - {jinx_name}\n"
1755
1790
  else:
1756
1791
  str_rep += "No jinxs available.\n"
1757
1792
  return str_rep
@@ -1769,13 +1804,11 @@ class NPC:
1769
1804
 
1770
1805
  input_values = extract_jinx_inputs(args, jinx)
1771
1806
 
1772
-
1773
-
1774
-
1775
1807
  jinx_output = jinx.execute(
1776
1808
  input_values,
1777
- jinx.jinx_name,
1778
1809
  npc=self,
1810
+ messages=messages, # Pass messages to Jinx.execute
1811
+ jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
1779
1812
  )
1780
1813
 
1781
1814
  return {"messages": messages, "output": jinx_output}
@@ -1937,12 +1970,14 @@ class NPC:
1937
1970
  class Team:
1938
1971
  def __init__(self,
1939
1972
  team_path=None,
1940
- npcs=None,
1941
- forenpc=None,
1942
- jinxs=None,
1973
+ npcs: Optional[List['NPC']] = None, # Explicitly type hint as list of NPC
1974
+ forenpc: Optional[Union[str, 'NPC']] = None, # Can be name (str) or NPC object
1975
+ jinxs: Optional[List[Union['Jinx', Dict[str, Any]]]] = None, # List of raw Jinx objects or dicts
1943
1976
  db_conn=None,
1944
1977
  model = None,
1945
- provider = None):
1978
+ provider = None,
1979
+ api_url = None,
1980
+ api_key = None):
1946
1981
  """
1947
1982
  Initialize an NPC team from directory or list of NPCs
1948
1983
 
@@ -1953,45 +1988,249 @@ class Team:
1953
1988
  """
1954
1989
  self.model = model
1955
1990
  self.provider = provider
1991
+ self.api_url = api_url
1992
+ self.api_key = api_key
1956
1993
 
1957
- self.npcs = {}
1958
- self.sub_teams = {}
1959
- self.jinxs_dict = jinxs or {}
1994
+ self.npcs: Dict[str, 'NPC'] = {} # Store NPC objects by name
1995
+ self.sub_teams: Dict[str, 'Team'] = {}
1996
+ self.jinxs_dict: Dict[str, 'Jinx'] = {} # This will store first-pass rendered Jinx objects
1997
+ self._raw_jinxs_list: List['Jinx'] = [] # Temporary storage for raw Team-level Jinx objects
1998
+
1999
+ self.jinja_env_for_first_pass = Environment(undefined=SilentUndefined) # Env for macro expansion
2000
+
1960
2001
  self.db_conn = db_conn
1961
2002
  self.team_path = os.path.expanduser(team_path) if team_path else None
1962
2003
  self.databases = []
1963
2004
  self.mcp_servers = []
1964
- if forenpc is not None:
1965
- self.forenpc = forenpc
1966
- else:
1967
- self.forenpc = npcs[0] if npcs else None
1968
2005
 
2006
+ self.forenpc: Optional['NPC'] = None # Will be set to an NPC object by end of __init__
2007
+ self.forenpc_name: Optional[str] = None # Temporary storage for name from context (if loaded from .ctx)
2008
+
1969
2009
  if team_path:
1970
2010
  self.name = os.path.basename(os.path.abspath(team_path))
1971
- else:
2011
+ self._load_from_directory_and_initialize_forenpc()
2012
+ elif npcs:
1972
2013
  self.name = "custom_team"
2014
+ # Add provided NPCs and set their team attribute
2015
+ for npc_obj in npcs:
2016
+ self.npcs[npc_obj.name] = npc_obj
2017
+ npc_obj.team = self # Crucial: set the team for pre-existing NPCs
2018
+
2019
+ if jinxs: # Load raw team-level jinxs if provided
2020
+ for jinx_item in jinxs:
2021
+ if isinstance(jinx_item, Jinx):
2022
+ self._raw_jinxs_list.append(jinx_item)
2023
+ elif isinstance(jinx_item, dict):
2024
+ self._raw_jinxs_list.append(Jinx(jinx_data=jinx_item))
2025
+ # Assuming string jinxs are paths or names to be loaded later if needed.
2026
+
2027
+ self._determine_forenpc_from_provided_npcs(npcs, forenpc)
2028
+
2029
+ else: # No team_path and no npcs list, create a default forenpc
2030
+ self.name = "custom_team"
2031
+ self._create_default_forenpc()
2032
+
1973
2033
  self.context = ''
1974
2034
  self.shared_context = {
1975
2035
  "intermediate_results": {},
1976
2036
  "dataframes": {},
1977
2037
  "memories": {},
1978
2038
  "execution_history": [],
1979
- "npc_messages": {},
1980
2039
  "context":''
1981
2040
  }
1982
-
2041
+
2042
+ # Load team context into shared_context after forenpc is determined
2043
+ # This is for teams loaded from directory. For custom/default teams, context is set below.
1983
2044
  if team_path:
1984
- self._load_from_directory()
1985
-
1986
- elif npcs:
1987
- for npc in npcs:
1988
- self.npcs[npc.name] = npc
2045
+ self._load_team_context_into_shared_context()
2046
+ elif self.forenpc: # For custom teams or default, set basic context if not already set
2047
+ if not self.context: # Only set if context is still empty
2048
+ self.context = f"Team '{self.name}' with forenpc '{self.forenpc.name}'"
2049
+ self.shared_context['context'] = self.context
2050
+
2051
+ # Perform first-pass rendering for team-level jinxs
2052
+ self._perform_first_pass_jinx_rendering()
1989
2053
 
1990
- self.jinja_env = Environment(undefined=SilentUndefined)
2054
+ # Now, initialize jinxs for all NPCs, as team-level jinxs are ready
2055
+ for npc_obj in self.npcs.values():
2056
+ # Pass the team's raw jinxs to the NPC for its own first-pass rendering
2057
+ npc_obj.initialize_jinxs(team_raw_jinxs=self._raw_jinxs_list)
1991
2058
 
1992
2059
  if db_conn is not None:
1993
2060
  init_db_tables()
1994
2061
 
2062
+ def _load_from_directory_and_initialize_forenpc(self):
2063
+ """
2064
+ Consolidated method to load NPCs, team context, and resolve the forenpc.
2065
+ Ensures self.npcs is populated and self.forenpc is an NPC object.
2066
+ """
2067
+ if not os.path.exists(self.team_path):
2068
+ raise ValueError(f"Team directory not found: {self.team_path}")
2069
+
2070
+ # 1. Load all NPCs first (without initializing their jinxs yet)
2071
+ for filename in os.listdir(self.team_path):
2072
+ if filename.endswith(".npc"):
2073
+ npc_path = os.path.join(self.team_path, filename)
2074
+ # Pass 'self' to NPC constructor for team reference
2075
+ # Do NOT pass jinxs=... here, as it will be initialized later
2076
+ npc = NPC(npc_path, db_conn=self.db_conn, team=self)
2077
+ self.npcs[npc.name] = npc
2078
+
2079
+ # 2. Load team context and determine forenpc name (string)
2080
+ self._load_team_context_file() # This populates self.model, self.provider, self.forenpc_name etc.
2081
+
2082
+ # 3. Resolve and set self.forenpc (NPC object)
2083
+ if self.forenpc_name and self.forenpc_name in self.npcs:
2084
+ self.forenpc = self.npcs[self.forenpc_name]
2085
+ elif self.npcs: # Fallback to first NPC if name not found or not specified
2086
+ self.forenpc = list(self.npcs.values())[0]
2087
+ self.forenpc_name = self.forenpc.name # Update forenpc_name for consistency
2088
+ else: # No NPCs loaded, create a default forenpc
2089
+ self._create_default_forenpc()
2090
+
2091
+ # 4. Load raw Jinxs from team directory
2092
+ jinxs_dir = os.path.join(self.team_path, "jinxs")
2093
+ if os.path.exists(jinxs_dir):
2094
+ for jinx_obj in load_jinxs_from_directory(jinxs_dir):
2095
+ self._raw_jinxs_list.append(jinx_obj)
2096
+
2097
+ # 5. Load sub-teams
2098
+ self._load_sub_teams()
2099
+
2100
+ def _load_team_context_file(self) -> Dict[str, Any]:
2101
+ """Loads team context from .ctx file and updates team attributes."""
2102
+ ctx_data = {}
2103
+ for fname in os.listdir(self.team_path):
2104
+ if fname.endswith('.ctx'):
2105
+ ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
2106
+ if ctx_data is not None:
2107
+ self.model = ctx_data.get('model', self.model)
2108
+ self.provider = ctx_data.get('provider', self.provider)
2109
+ self.api_url = ctx_data.get('api_url', self.api_url)
2110
+ self.env = ctx_data.get('env', self.env if hasattr(self, 'env') else None)
2111
+ self.mcp_servers = ctx_data.get('mcp_servers', [])
2112
+ self.databases = ctx_data.get('databases', [])
2113
+ self.forenpc_name = ctx_data.get('forenpc', self.forenpc_name) # Set forenpc_name (string)
2114
+ return ctx_data
2115
+ return {}
2116
+
2117
+ def _load_team_context_into_shared_context(self):
2118
+ """Loads team context into shared_context after forenpc is determined."""
2119
+ ctx_data = {}
2120
+ for fname in os.listdir(self.team_path):
2121
+ if fname.endswith('.ctx'):
2122
+ ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
2123
+ if ctx_data is not None:
2124
+ self.context = ctx_data.get('context', '')
2125
+ self.shared_context['context'] = self.context
2126
+ if 'file_patterns' in ctx_data:
2127
+ file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
2128
+ self.shared_context['files'] = file_cache
2129
+ if 'preferences' in ctx_data:
2130
+ self.preferences = ctx_data['preferences']
2131
+ else:
2132
+ self.preferences = []
2133
+
2134
+ for key, item in ctx_data.items():
2135
+ if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env', 'preferences']:
2136
+ self.shared_context[key] = item
2137
+ return # Only load the first .ctx file found
2138
+
2139
+ def _determine_forenpc_from_provided_npcs(self, npcs_list: List['NPC'], forenpc_arg: Optional[Union[str, 'NPC']]):
2140
+ """Determines self.forenpc when NPCs are provided directly to Team.__init__."""
2141
+ if forenpc_arg:
2142
+ if isinstance(forenpc_arg, NPC):
2143
+ self.forenpc = forenpc_arg
2144
+ self.forenpc_name = forenpc_arg.name
2145
+ elif isinstance(forenpc_arg, str) and forenpc_arg in self.npcs:
2146
+ self.forenpc = self.npcs[forenpc_arg]
2147
+ self.forenpc_name = forenpc_arg
2148
+ else:
2149
+ print(f"Warning: Specified forenpc '{forenpc_arg}' not found among provided NPCs. Falling back to first NPC.")
2150
+ if npcs_list:
2151
+ self.forenpc = npcs_list[0]
2152
+ self.forenpc_name = npcs_list[0].name
2153
+ else:
2154
+ self._create_default_forenpc()
2155
+ elif npcs_list: # Default to first NPC if no forenpc_arg
2156
+ self.forenpc = npcs_list[0]
2157
+ self.forenpc_name = npcs_list[0].name
2158
+ else: # No NPCs provided, create a default forenpc
2159
+ self._create_default_forenpc()
2160
+
2161
+ def _create_default_forenpc(self):
2162
+ """Creates a default forenpc if none can be determined."""
2163
+ forenpc_model = self.model or 'llama3.2'
2164
+ forenpc_provider = self.provider or 'ollama'
2165
+ forenpc_api_key = self.api_key
2166
+ forenpc_api_url = self.api_url
2167
+
2168
+ default_forenpc = NPC(name='forenpc',
2169
+ primary_directive="""You are the forenpc of the team, coordinating activities
2170
+ between NPCs on the team, verifying that results from
2171
+ NPCs are high quality and can help to adequately answer
2172
+ user requests.""",
2173
+ model=forenpc_model,
2174
+ provider=forenpc_provider,
2175
+ api_key=forenpc_api_key,
2176
+ api_url=forenpc_api_url,
2177
+ team=self # Pass the team to the forenpc
2178
+ )
2179
+ self.forenpc = default_forenpc
2180
+ self.forenpc_name = default_forenpc.name
2181
+ self.npcs[default_forenpc.name] = default_forenpc # Add to team's NPC list
2182
+
2183
+ def _perform_first_pass_jinx_rendering(self):
2184
+ """
2185
+ Performs the first-pass Jinja rendering on all loaded raw Jinxs.
2186
+ This expands nested Jinx calls but preserves runtime variables.
2187
+ """
2188
+ # Create Jinja globals for calling other Jinxs as macros
2189
+ jinx_macro_globals = {}
2190
+ for raw_jinx in self._raw_jinxs_list:
2191
+ def create_jinx_callable(jinx_obj_in_closure):
2192
+ def callable_jinx(**kwargs):
2193
+ # This callable will be invoked by the Jinja renderer during the first pass.
2194
+ # It needs to render the target Jinx's *raw* steps with the provided kwargs.
2195
+ temp_jinja_env = Environment(undefined=SilentUndefined)
2196
+
2197
+ rendered_target_steps = []
2198
+ for target_step in jinx_obj_in_closure._raw_steps:
2199
+ temp_rendered_step = {}
2200
+ for k, v in target_step.items():
2201
+ if isinstance(v, str):
2202
+ try:
2203
+ # Render the string, using kwargs as context.
2204
+ # SilentUndefined will ensure {{ var }} that are not in kwargs remain as is.
2205
+ temp_rendered_step[k] = temp_jinja_env.from_string(v).render(**kwargs)
2206
+ except Exception as e:
2207
+ print(f"Warning: Error in Jinx macro '{jinx_obj_in_closure.jinx_name}' rendering step field '{k}' (Team first pass): {e}")
2208
+ temp_rendered_step[k] = v
2209
+ else:
2210
+ temp_rendered_step[k] = v
2211
+ rendered_target_steps.append(temp_rendered_step)
2212
+
2213
+ # Return the YAML string representation of the rendered steps
2214
+ return yaml.dump(rendered_target_steps, default_flow_style=False)
2215
+ return callable_jinx
2216
+
2217
+ jinx_macro_globals[raw_jinx.jinx_name] = create_jinx_callable(raw_jinx)
2218
+
2219
+ self.jinja_env_for_first_pass.globals['jinxs'] = jinx_macro_globals # Make 'jinxs.jinx_name' callable
2220
+ self.jinja_env_for_first_pass.globals.update(jinx_macro_globals) # Also make 'jinx_name' callable directly
2221
+
2222
+ # Now, iterate through the raw Jinxs and perform the first-pass rendering
2223
+ for raw_jinx in self._raw_jinxs_list:
2224
+ try:
2225
+ # Pass the jinx_macro_globals to render_first_pass so it can resolve declarative calls
2226
+ raw_jinx.render_first_pass(self.jinja_env_for_first_pass, jinx_macro_globals)
2227
+ self.jinxs_dict[raw_jinx.jinx_name] = raw_jinx # Store the first-pass rendered Jinx
2228
+ except Exception as e:
2229
+ print(f"Error performing first-pass rendering for Jinx '{raw_jinx.jinx_name}': {e}")
2230
+
2231
+ self._raw_jinxs_list = [] # Clear temporary storage
2232
+
2233
+
1995
2234
  def update_context(self, messages: list):
1996
2235
  """Update team context based on recent conversation patterns"""
1997
2236
  if len(messages) < 10:
@@ -2035,77 +2274,6 @@ class Team:
2035
2274
  with open(team_ctx_path, 'w') as f:
2036
2275
  yaml.dump(ctx_data, f)
2037
2276
 
2038
- def _load_from_directory(self):
2039
- """Load team from directory"""
2040
- if not os.path.exists(self.team_path):
2041
- raise ValueError(f"Team directory not found: {self.team_path}")
2042
-
2043
- for filename in os.listdir(self.team_path):
2044
- if filename.endswith(".npc"):
2045
- npc_path = os.path.join(self.team_path, filename)
2046
- npc = NPC(npc_path, db_conn=self.db_conn)
2047
- self.npcs[npc.name] = npc
2048
-
2049
- self.context = self._load_team_context()
2050
-
2051
- jinxs_dir = os.path.join(self.team_path, "jinxs")
2052
- if os.path.exists(jinxs_dir):
2053
- for jinx in load_jinxs_from_directory(jinxs_dir):
2054
- self.jinxs_dict[jinx.jinx_name] = jinx
2055
-
2056
- self._load_sub_teams()
2057
-
2058
- def _load_team_context(self):
2059
- """Load team context from .ctx file"""
2060
- for fname in os.listdir(self.team_path):
2061
- if fname.endswith('.ctx'):
2062
- ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
2063
- if ctx_data is not None:
2064
- if 'model' in ctx_data:
2065
- self.model = ctx_data['model']
2066
- else:
2067
- self.model = None
2068
- if 'provider' in ctx_data:
2069
- self.provider = ctx_data['provider']
2070
- else:
2071
- self.provider = None
2072
- if 'api_url' in ctx_data:
2073
- self.api_url = ctx_data['api_url']
2074
- else:
2075
- self.api_url = None
2076
- if 'env' in ctx_data:
2077
- self.env = ctx_data['env']
2078
- else:
2079
- self.env = None
2080
-
2081
- if 'mcp_servers' in ctx_data:
2082
- self.mcp_servers = ctx_data['mcp_servers']
2083
- else:
2084
- self.mcp_servers = []
2085
- if 'databases' in ctx_data:
2086
- self.databases = ctx_data['databases']
2087
- else:
2088
- self.databases = []
2089
-
2090
- base_context = ctx_data.get('context', '')
2091
- self.shared_context['context'] = base_context
2092
- if 'file_patterns' in ctx_data:
2093
- file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
2094
- self.shared_context['files'] = file_cache
2095
- if 'preferences' in ctx_data:
2096
- self.preferences = ctx_data['preferences']
2097
- else:
2098
- self.preferences = []
2099
- if 'forenpc' in ctx_data:
2100
- self.forenpc = self.npcs[ctx_data['forenpc']]
2101
- else:
2102
- self.forenpc = self.npcs[list(self.npcs.keys())[0]] if self.npcs else None
2103
- for key, item in ctx_data.items():
2104
- if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns']:
2105
- self.shared_context[key] = item
2106
- return ctx_data
2107
- return {}
2108
-
2109
2277
  def _load_sub_teams(self):
2110
2278
  """Load sub-teams from subdirectories"""
2111
2279
  for item in os.listdir(self.team_path):
@@ -2119,47 +2287,14 @@ class Team:
2119
2287
  sub_team = Team(team_path=item_path, db_conn=self.db_conn)
2120
2288
  self.sub_teams[item] = sub_team
2121
2289
 
2122
- def get_forenpc(self):
2290
+ def get_forenpc(self) -> Optional['NPC']:
2123
2291
  """
2124
- Get the forenpc (coordinator) for this team.
2125
- The forenpc is set only if explicitly defined in the context.
2126
-
2292
+ Returns the forenpc (coordinator) for this team.
2293
+ This method is now primarily for external access, as self.forenpc is set in __init__.
2127
2294
  """
2128
- if isinstance(self.forenpc, NPC):
2129
- return self.forenpc
2130
- if hasattr(self, 'context') and self.context and 'forenpc' in self.context:
2131
- forenpc_ref = self.context['forenpc']
2132
-
2133
- if '{{ref(' in forenpc_ref:
2134
- match = re.search(r"{{\s*ref\('([^']+)'\)\s*}}", forenpc_ref)
2135
- if match:
2136
- forenpc_name = match.group(1)
2137
- if forenpc_name in self.npcs:
2138
- return self.npcs[forenpc_name]
2139
- elif forenpc_ref in self.npcs:
2140
- return self.npcs[forenpc_ref]
2141
- else:
2142
- forenpc_model=self.context.get('model', 'llama3.2'),
2143
- forenpc_provider=self.context.get('provider', 'ollama'),
2144
- forenpc_api_key=self.context.get('api_key', None),
2145
- forenpc_api_url=self.context.get('api_url', None)
2146
-
2147
- forenpc = NPC(name='forenpc',
2148
- primary_directive="""You are the forenpc of the team, coordinating activities
2149
- between NPCs on the team, verifying that results from
2150
- NPCs are high quality and can help to adequately answer
2151
- user requests.""",
2152
- model=forenpc_model,
2153
- provider=forenpc_provider,
2154
- api_key=forenpc_api_key,
2155
- api_url=forenpc_api_url,
2156
- )
2157
- self.forenpc = forenpc
2158
- self.npcs[forenpc.name] = forenpc
2159
- return forenpc
2160
- return None
2295
+ return self.forenpc
2161
2296
 
2162
- def get_npc(self, npc_ref):
2297
+ def get_npc(self, npc_ref: Union[str, 'NPC']) -> Optional['NPC']:
2163
2298
  """Get NPC by name or reference with hierarchical lookup capability"""
2164
2299
  if isinstance(npc_ref, NPC):
2165
2300
  return npc_ref
@@ -2181,7 +2316,7 @@ class Team:
2181
2316
 
2182
2317
  def orchestrate(self, request):
2183
2318
  """Orchestrate a request through the team"""
2184
- forenpc = self.get_forenpc()
2319
+ forenpc = self.get_forenpc() # Now guaranteed to be an NPC object
2185
2320
  if not forenpc:
2186
2321
  return {"error": "No forenpc available to coordinate the team"}
2187
2322
 
@@ -2326,7 +2461,7 @@ class Team:
2326
2461
  "name": self.name,
2327
2462
  "npcs": {name: npc.to_dict() for name, npc in self.npcs.items()},
2328
2463
  "sub_teams": {name: team.to_dict() for name, team in self.sub_teams.items()},
2329
- "jinxs": {name: jinx.to_dict() for name, jinx in self.jinxs.items()},
2464
+ "jinxs": {name: jinx.to_dict() for name, jinx in self.jinxs_dict.items()}, # Use jinxs_dict
2330
2465
  "context": getattr(self, 'context', {})
2331
2466
  }
2332
2467
 
@@ -2350,7 +2485,7 @@ class Team:
2350
2485
  jinxs_dir = os.path.join(directory, "jinxs")
2351
2486
  ensure_dirs_exist(jinxs_dir)
2352
2487
 
2353
- for jinx in self.jinxs.values():
2488
+ for jinx in self.jinxs_dict.values(): # Use jinxs_dict
2354
2489
  jinx.save(jinxs_dir)
2355
2490
 
2356
2491
  for team_name, team in self.sub_teams.items():