npcpy 1.2.32__py3-none-any.whl → 1.2.33__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/llm_funcs.py +57 -37
- npcpy/npc_compiler.py +549 -350
- {npcpy-1.2.32.dist-info → npcpy-1.2.33.dist-info}/METADATA +97 -34
- {npcpy-1.2.32.dist-info → npcpy-1.2.33.dist-info}/RECORD +7 -7
- {npcpy-1.2.32.dist-info → npcpy-1.2.33.dist-info}/WHEEL +0 -0
- {npcpy-1.2.32.dist-info → npcpy-1.2.33.dist-info}/licenses/LICENSE +0 -0
- {npcpy-1.2.32.dist-info → npcpy-1.2.33.dist-info}/top_level.txt +0 -0
npcpy/npc_compiler.py
CHANGED
|
@@ -13,8 +13,8 @@ import hashlib
|
|
|
13
13
|
import pathlib
|
|
14
14
|
import fnmatch
|
|
15
15
|
import subprocess
|
|
16
|
-
from typing import Any, Dict, List, Optional, Union
|
|
17
|
-
from jinja2 import Environment, FileSystemLoader, Template, Undefined
|
|
16
|
+
from typing import Any, Dict, List, Optional, Union, Callable, Tuple
|
|
17
|
+
from jinja2 import Environment, FileSystemLoader, Template, Undefined, DictLoader
|
|
18
18
|
from sqlalchemy import create_engine, text
|
|
19
19
|
import npcpy as npy
|
|
20
20
|
from npcpy.llm_funcs import DEFAULT_ACTION_SPACE
|
|
@@ -228,16 +228,23 @@ def write_yaml_file(file_path, data):
|
|
|
228
228
|
print(f"Error writing YAML file {file_path}: {e}")
|
|
229
229
|
return False
|
|
230
230
|
|
|
231
|
-
|
|
232
231
|
class Jinx:
|
|
233
232
|
'''
|
|
233
|
+
Jinx represents a workflow template with Jinja-rendered steps.
|
|
234
|
+
|
|
235
|
+
Loads YAML definition containing:
|
|
236
|
+
- jinx_name: identifier
|
|
237
|
+
- inputs: list of input parameters
|
|
238
|
+
- description: what the jinx does
|
|
239
|
+
- npc: optional NPC to execute with
|
|
240
|
+
- steps: list of step definitions with code
|
|
234
241
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
Execution:
|
|
243
|
+
- Renders Jinja templates in step code with input values
|
|
244
|
+
- Executes resulting Python code
|
|
245
|
+
- Returns context with outputs
|
|
238
246
|
'''
|
|
239
247
|
def __init__(self, jinx_data=None, jinx_path=None):
|
|
240
|
-
"""Initialize a jinx from data or file path"""
|
|
241
248
|
if jinx_path:
|
|
242
249
|
self._load_from_file(jinx_path)
|
|
243
250
|
elif jinx_data:
|
|
@@ -245,15 +252,17 @@ class Jinx:
|
|
|
245
252
|
else:
|
|
246
253
|
raise ValueError("Either jinx_data or jinx_path must be provided")
|
|
247
254
|
|
|
255
|
+
# Store the raw steps as loaded from YAML
|
|
256
|
+
self._raw_steps = list(self.steps) # Make a copy to preserve original
|
|
257
|
+
self.steps = [] # This will hold the steps after the first Jinja pass
|
|
258
|
+
|
|
248
259
|
def _load_from_file(self, path):
|
|
249
|
-
"""Load jinx from file"""
|
|
250
260
|
jinx_data = load_yaml_file(path)
|
|
251
261
|
if not jinx_data:
|
|
252
262
|
raise ValueError(f"Failed to load jinx from {path}")
|
|
253
263
|
self._load_from_data(jinx_data)
|
|
254
264
|
|
|
255
265
|
def _load_from_data(self, jinx_data):
|
|
256
|
-
"""Load jinx from data dictionary"""
|
|
257
266
|
if not jinx_data or not isinstance(jinx_data, dict):
|
|
258
267
|
raise ValueError("Invalid jinx data provided")
|
|
259
268
|
|
|
@@ -263,62 +272,147 @@ class Jinx:
|
|
|
263
272
|
self.jinx_name = jinx_data.get("jinx_name")
|
|
264
273
|
self.inputs = jinx_data.get("inputs", [])
|
|
265
274
|
self.description = jinx_data.get("description", "")
|
|
266
|
-
self.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
self.npc = jinx_data.get("npc")
|
|
276
|
+
self.steps = jinx_data.get("steps", []) # These are the raw steps initially
|
|
277
|
+
|
|
278
|
+
def render_first_pass(
|
|
279
|
+
self,
|
|
280
|
+
jinja_env_for_macros: Environment,
|
|
281
|
+
all_jinx_callables: Dict[str, Callable]
|
|
282
|
+
):
|
|
283
|
+
"""
|
|
284
|
+
Performs the first-pass Jinja rendering on the Jinx's raw steps.
|
|
285
|
+
This expands nested Jinx calls (e.g., {{ sh(...) }} or
|
|
286
|
+
engine: jinx_name) but preserves runtime variables
|
|
287
|
+
(e.g., {{ command_var }}).
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
jinja_env_for_macros: The Jinja Environment configured with
|
|
291
|
+
Jinx callables in its globals.
|
|
292
|
+
all_jinx_callables: A dictionary of Jinx names to their
|
|
293
|
+
callable functions (from create_jinx_callable).
|
|
294
|
+
"""
|
|
295
|
+
rendered_steps_output = []
|
|
296
|
+
|
|
297
|
+
for raw_step in self._raw_steps:
|
|
298
|
+
if not isinstance(raw_step, dict):
|
|
299
|
+
rendered_steps_output.append(raw_step)
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
engine_name = raw_step.get('engine')
|
|
303
|
+
|
|
304
|
+
if engine_name and engine_name in all_jinx_callables:
|
|
305
|
+
step_name = raw_step.get('name', f'call_{engine_name}')
|
|
306
|
+
jinx_args = {
|
|
307
|
+
k: v for k, v in raw_step.items()
|
|
308
|
+
if k not in ['engine', 'name']
|
|
276
309
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
310
|
+
|
|
311
|
+
jinx_callable = all_jinx_callables[engine_name]
|
|
312
|
+
try:
|
|
313
|
+
expanded_yaml_string = jinx_callable(**jinx_args)
|
|
314
|
+
expanded_steps = yaml.safe_load(expanded_yaml_string)
|
|
315
|
+
|
|
316
|
+
if isinstance(expanded_steps, list):
|
|
317
|
+
context_setup_step = {
|
|
318
|
+
'name': f'{step_name}_setup',
|
|
319
|
+
'engine': 'python',
|
|
320
|
+
'code': (
|
|
321
|
+
f'# Setup inputs for {engine_name}\n' +
|
|
322
|
+
'\n'.join([
|
|
323
|
+
f"context['{k}'] = {repr(v)}"
|
|
324
|
+
for k, v in jinx_args.items()
|
|
325
|
+
])
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
rendered_steps_output.append(context_setup_step)
|
|
329
|
+
rendered_steps_output.extend(expanded_steps)
|
|
330
|
+
|
|
331
|
+
context_result_step = {
|
|
332
|
+
'name': step_name,
|
|
333
|
+
'engine': 'python',
|
|
334
|
+
'code': (
|
|
335
|
+
f"context['{step_name}'] = "
|
|
336
|
+
f"{{'output': context.get('output', None)}}\n"
|
|
337
|
+
f"output = context['{step_name}']"
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
rendered_steps_output.append(context_result_step)
|
|
341
|
+
|
|
342
|
+
elif expanded_steps is not None:
|
|
343
|
+
rendered_steps_output.append(expanded_steps)
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
print(
|
|
347
|
+
f"Warning: Error expanding Jinx '{engine_name}' "
|
|
348
|
+
f"within Jinx '{self.jinx_name}' "
|
|
349
|
+
f"(declarative): {e}"
|
|
350
|
+
)
|
|
351
|
+
rendered_steps_output.append(raw_step)
|
|
280
352
|
else:
|
|
281
|
-
|
|
282
|
-
|
|
353
|
+
processed_step = {}
|
|
354
|
+
for key, value in raw_step.items():
|
|
355
|
+
if isinstance(value, str):
|
|
356
|
+
try:
|
|
357
|
+
template = jinja_env_for_macros.from_string(
|
|
358
|
+
value
|
|
359
|
+
)
|
|
360
|
+
rendered_value = template.render({})
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
loaded_value = yaml.safe_load(
|
|
364
|
+
rendered_value
|
|
365
|
+
)
|
|
366
|
+
processed_step[key] = loaded_value
|
|
367
|
+
except yaml.YAMLError:
|
|
368
|
+
processed_step[key] = rendered_value
|
|
369
|
+
except Exception as e:
|
|
370
|
+
print(
|
|
371
|
+
f"Warning: Error during first-pass "
|
|
372
|
+
f"rendering of Jinx '{self.jinx_name}' "
|
|
373
|
+
f"step field '{key}' (inline macro): {e}"
|
|
374
|
+
)
|
|
375
|
+
processed_step[key] = value
|
|
376
|
+
else:
|
|
377
|
+
processed_step[key] = value
|
|
378
|
+
rendered_steps_output.append(processed_step)
|
|
379
|
+
|
|
380
|
+
self.steps = rendered_steps_output
|
|
283
381
|
|
|
284
382
|
def execute(self,
|
|
285
383
|
input_values: Dict[str, Any],
|
|
286
|
-
jinxs_dict: Dict[str, 'Jinx'],
|
|
287
|
-
jinja_env: Optional[Environment] = None,
|
|
288
384
|
npc: Optional[Any] = None,
|
|
289
385
|
messages: Optional[List[Dict[str, str]]] = None,
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"""
|
|
386
|
+
extra_globals: Optional[Dict[str, Any]] = None,
|
|
387
|
+
jinja_env: Optional[Environment] = None): # Add jinja_env here for the second pass
|
|
388
|
+
|
|
389
|
+
# If jinja_env is not provided, create a default one for the second pass
|
|
295
390
|
if jinja_env is None:
|
|
296
|
-
from jinja2 import DictLoader
|
|
297
391
|
jinja_env = Environment(
|
|
298
392
|
loader=DictLoader({}),
|
|
299
393
|
undefined=SilentUndefined,
|
|
300
394
|
)
|
|
301
395
|
|
|
302
|
-
|
|
396
|
+
active_npc = self.npc if self.npc else npc
|
|
397
|
+
|
|
398
|
+
context = (active_npc.shared_context.copy() if active_npc and hasattr(active_npc, 'shared_context') else {})
|
|
303
399
|
context.update(input_values)
|
|
304
400
|
context.update({
|
|
305
|
-
"jinxs": jinxs_dict,
|
|
306
401
|
"llm_response": None,
|
|
307
402
|
"output": None,
|
|
308
403
|
"messages": messages,
|
|
404
|
+
"npc": active_npc
|
|
309
405
|
})
|
|
310
|
-
|
|
311
|
-
# This is the key change: Extract 'extra_globals' from kwargs
|
|
312
|
-
extra_globals = kwargs.get('extra_globals')
|
|
313
406
|
|
|
407
|
+
# Iterate over self.steps, which are now first-pass rendered
|
|
314
408
|
for i, step in enumerate(self.steps):
|
|
315
409
|
context = self._execute_step(
|
|
316
410
|
step,
|
|
317
411
|
context,
|
|
318
|
-
jinja_env,
|
|
319
|
-
npc=
|
|
412
|
+
jinja_env, # This is the second-pass Jinja env
|
|
413
|
+
npc=active_npc,
|
|
320
414
|
messages=messages,
|
|
321
|
-
extra_globals=extra_globals
|
|
415
|
+
extra_globals=extra_globals
|
|
322
416
|
)
|
|
323
417
|
|
|
324
418
|
return context
|
|
@@ -326,143 +420,103 @@ class Jinx:
|
|
|
326
420
|
def _execute_step(self,
|
|
327
421
|
step: Dict[str, Any],
|
|
328
422
|
context: Dict[str, Any],
|
|
329
|
-
jinja_env: Environment,
|
|
423
|
+
jinja_env: Environment, # This is for the second pass (runtime vars)
|
|
330
424
|
npc: Optional[Any] = None,
|
|
331
425
|
messages: Optional[List[Dict[str, str]]] = None,
|
|
332
426
|
extra_globals: Optional[Dict[str, Any]] = None):
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"""
|
|
336
|
-
engine = step.get("engine", "natural")
|
|
337
|
-
code = step.get("code", "")
|
|
427
|
+
|
|
428
|
+
code_content = step.get("code", "") # Get the code content, which might still have {{ runtime_var }}
|
|
338
429
|
step_name = step.get("name", "unnamed_step")
|
|
339
|
-
|
|
340
|
-
|
|
430
|
+
step_npc = step.get("npc")
|
|
431
|
+
|
|
432
|
+
active_npc = step_npc if step_npc else npc
|
|
433
|
+
|
|
434
|
+
# Second pass rendering: resolve runtime variables in the code content
|
|
341
435
|
try:
|
|
342
|
-
template = jinja_env.from_string(
|
|
436
|
+
template = jinja_env.from_string(code_content)
|
|
343
437
|
rendered_code = template.render(**context)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
438
|
+
except Exception as e:
|
|
439
|
+
print(f"Error rendering template for step {step_name} (second pass): {e}")
|
|
440
|
+
rendered_code = code_content # Fallback to unrendered if error
|
|
441
|
+
|
|
442
|
+
exec_globals = {
|
|
443
|
+
"__builtins__": __builtins__,
|
|
444
|
+
"npc": active_npc,
|
|
445
|
+
"context": context,
|
|
446
|
+
"pd": pd,
|
|
447
|
+
"plt": plt,
|
|
448
|
+
"np": np,
|
|
449
|
+
"os": os,
|
|
450
|
+
're': re,
|
|
451
|
+
"json": json,
|
|
452
|
+
"Path": pathlib.Path,
|
|
453
|
+
"fnmatch": fnmatch,
|
|
454
|
+
"pathlib": pathlib,
|
|
455
|
+
"subprocess": subprocess,
|
|
456
|
+
"get_llm_response": npy.llm_funcs.get_llm_response,
|
|
457
|
+
"CommandHistory": CommandHistory,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if extra_globals:
|
|
461
|
+
exec_globals.update(extra_globals)
|
|
347
462
|
|
|
463
|
+
exec_locals = {}
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
exec(rendered_code, exec_globals, exec_locals)
|
|
348
467
|
except Exception as e:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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')
|
|
468
|
+
error_msg = f"Error executing step {step_name}: {type(e).__name__}: {e}"
|
|
469
|
+
context['output'] = error_msg
|
|
470
|
+
print(error_msg)
|
|
471
|
+
return context
|
|
472
|
+
|
|
473
|
+
context.update(exec_locals)
|
|
474
|
+
|
|
475
|
+
if "output" in exec_locals:
|
|
476
|
+
outp = exec_locals["output"]
|
|
477
|
+
context["output"] = outp
|
|
478
|
+
context[step_name] = outp
|
|
479
|
+
if messages is not None:
|
|
480
|
+
messages.append({
|
|
481
|
+
'role':'assistant',
|
|
482
|
+
'content': f'Jinx {self.jinx_name} step {step_name} executed: {outp}'
|
|
483
|
+
})
|
|
484
|
+
context['messages'] = messages
|
|
376
485
|
|
|
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
|
|
409
|
-
|
|
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
|
-
|
|
424
486
|
return context
|
|
487
|
+
|
|
425
488
|
def to_dict(self):
|
|
426
|
-
|
|
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
|
-
|
|
438
|
-
return {
|
|
489
|
+
result = {
|
|
439
490
|
"jinx_name": self.jinx_name,
|
|
440
491
|
"description": self.description,
|
|
441
492
|
"inputs": self.inputs,
|
|
442
|
-
|
|
493
|
+
# When converting to dict, we should save the *raw* steps
|
|
494
|
+
# so that when reloaded, the first pass can be done again.
|
|
495
|
+
"steps": self._raw_steps
|
|
443
496
|
}
|
|
497
|
+
|
|
498
|
+
if self.npc:
|
|
499
|
+
result["npc"] = self.npc
|
|
500
|
+
|
|
501
|
+
return result
|
|
502
|
+
|
|
444
503
|
def save(self, directory):
|
|
445
|
-
"""Save jinx to file"""
|
|
446
504
|
jinx_path = os.path.join(directory, f"{self.jinx_name}.jinx")
|
|
447
505
|
ensure_dirs_exist(os.path.dirname(jinx_path))
|
|
448
506
|
return write_yaml_file(jinx_path, self.to_dict())
|
|
449
507
|
|
|
450
508
|
@classmethod
|
|
451
509
|
def from_mcp(cls, mcp_tool):
|
|
452
|
-
"""Convert an MCP tool to NPC jinx format"""
|
|
453
|
-
|
|
454
510
|
try:
|
|
455
511
|
import inspect
|
|
456
512
|
|
|
457
|
-
|
|
458
513
|
doc = mcp_tool.__doc__ or ""
|
|
459
514
|
name = mcp_tool.__name__
|
|
460
515
|
signature = inspect.signature(mcp_tool)
|
|
461
516
|
|
|
462
|
-
|
|
463
517
|
inputs = []
|
|
464
518
|
for param_name, param in signature.parameters.items():
|
|
465
|
-
if param_name != 'self':
|
|
519
|
+
if param_name != 'self':
|
|
466
520
|
param_type = param.annotation if param.annotation != inspect.Parameter.empty else None
|
|
467
521
|
param_default = None if param.default == inspect.Parameter.empty else param.default
|
|
468
522
|
|
|
@@ -472,7 +526,6 @@ class Jinx:
|
|
|
472
526
|
"default": param_default
|
|
473
527
|
})
|
|
474
528
|
|
|
475
|
-
|
|
476
529
|
jinx_data = {
|
|
477
530
|
"jinx_name": name,
|
|
478
531
|
"description": doc.strip(),
|
|
@@ -480,9 +533,7 @@ class Jinx:
|
|
|
480
533
|
"steps": [
|
|
481
534
|
{
|
|
482
535
|
"name": "mcp_function_call",
|
|
483
|
-
"engine": "python",
|
|
484
536
|
"code": f"""
|
|
485
|
-
|
|
486
537
|
import {mcp_tool.__module__}
|
|
487
538
|
output = {mcp_tool.__module__}.{name}(
|
|
488
539
|
{', '.join([f'{inp["name"]}=context.get("{inp["name"]}")' for inp in inputs])}
|
|
@@ -495,7 +546,7 @@ output = {mcp_tool.__module__}.{name}(
|
|
|
495
546
|
return cls(jinx_data=jinx_data)
|
|
496
547
|
|
|
497
548
|
except:
|
|
498
|
-
pass
|
|
549
|
+
pass
|
|
499
550
|
|
|
500
551
|
def load_jinxs_from_directory(directory):
|
|
501
552
|
"""Load all jinxs from a directory recursively"""
|
|
@@ -687,8 +738,8 @@ class NPC:
|
|
|
687
738
|
name: str = None,
|
|
688
739
|
primary_directive: str = None,
|
|
689
740
|
plain_system_message: bool = False,
|
|
690
|
-
team = None,
|
|
691
|
-
jinxs: list = None,
|
|
741
|
+
team = None, # Can be None initially
|
|
742
|
+
jinxs: list = None, # Explicit jinxs for this NPC
|
|
692
743
|
tools: list = None,
|
|
693
744
|
model: str = None,
|
|
694
745
|
provider: str = None,
|
|
@@ -696,7 +747,7 @@ class NPC:
|
|
|
696
747
|
api_key: str = None,
|
|
697
748
|
db_conn=None,
|
|
698
749
|
use_global_jinxs=False,
|
|
699
|
-
memory = False,
|
|
750
|
+
memory = False,
|
|
700
751
|
**kwargs
|
|
701
752
|
):
|
|
702
753
|
"""
|
|
@@ -734,7 +785,9 @@ class NPC:
|
|
|
734
785
|
self.jinxs_directory = None
|
|
735
786
|
self.npc_directory = None
|
|
736
787
|
|
|
737
|
-
self.team = team
|
|
788
|
+
self.team = team # Store the team reference (can be None)
|
|
789
|
+
self.jinxs_spec = jinxs or "*" # Store the jinx specification for later loading
|
|
790
|
+
|
|
738
791
|
if tools is not None:
|
|
739
792
|
tools_schema, tool_map = auto_tools(tools)
|
|
740
793
|
self.tools = tools_schema
|
|
@@ -755,6 +808,7 @@ class NPC:
|
|
|
755
808
|
if self.jinxs_directory:
|
|
756
809
|
dirs.append(self.jinxs_directory)
|
|
757
810
|
|
|
811
|
+
# This jinja_env is for the *second pass* (runtime variable resolution in Jinx.execute)
|
|
758
812
|
self.jinja_env = Environment(
|
|
759
813
|
loader=FileSystemLoader([
|
|
760
814
|
os.path.expanduser(d) for d in dirs
|
|
@@ -764,7 +818,6 @@ class NPC:
|
|
|
764
818
|
|
|
765
819
|
self.db_conn = db_conn
|
|
766
820
|
|
|
767
|
-
# these 4 get overwritten if the db conn
|
|
768
821
|
self.command_history = None
|
|
769
822
|
self.kg_data = None
|
|
770
823
|
self.tables = None
|
|
@@ -777,9 +830,24 @@ class NPC:
|
|
|
777
830
|
self.kg_data = self._load_npc_kg()
|
|
778
831
|
self.memory = self.get_memory_context()
|
|
779
832
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
833
|
+
self.jinxs_dict = {} # Initialize empty, will be populated by initialize_jinxs
|
|
834
|
+
# If jinxs are explicitly provided *to the NPC* during its standalone creation, load them.
|
|
835
|
+
# This is for NPCs created *outside* a team context initially.
|
|
836
|
+
if jinxs and jinxs != "*":
|
|
837
|
+
for jinx_item in jinxs:
|
|
838
|
+
if isinstance(jinx_item, Jinx):
|
|
839
|
+
self.jinxs_dict[jinx_item.jinx_name] = jinx_item
|
|
840
|
+
elif isinstance(jinx_item, dict):
|
|
841
|
+
jinx_obj = Jinx(jinx_data=jinx_item)
|
|
842
|
+
self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
|
|
843
|
+
elif isinstance(jinx_item, str):
|
|
844
|
+
# Try to load from NPC's own directory first
|
|
845
|
+
jinx_path = find_file_path(jinx_item, [self.npc_jinxs_directory], suffix=".jinx")
|
|
846
|
+
if jinx_path:
|
|
847
|
+
jinx_obj = Jinx(jinx_path=jinx_path)
|
|
848
|
+
self.jinxs_dict[jinx_obj.jinx_name] = jinx_obj
|
|
849
|
+
else:
|
|
850
|
+
print(f"Warning: Jinx '{jinx_item}' not found for NPC '{self.name}' during initial load.")
|
|
783
851
|
|
|
784
852
|
self.shared_context = {
|
|
785
853
|
"dataframes": {},
|
|
@@ -794,6 +862,71 @@ class NPC:
|
|
|
794
862
|
if db_conn is not None:
|
|
795
863
|
init_db_tables()
|
|
796
864
|
|
|
865
|
+
def initialize_jinxs(self, team_raw_jinxs: Optional[List['Jinx']] = None):
|
|
866
|
+
"""
|
|
867
|
+
Loads and performs first-pass Jinja rendering for NPC-specific jinxs,
|
|
868
|
+
now that the NPC's team context is fully established.
|
|
869
|
+
"""
|
|
870
|
+
npc_jinxs_raw_list = []
|
|
871
|
+
|
|
872
|
+
# If jinxs_spec is "*", inherit all from team
|
|
873
|
+
if self.jinxs_spec == "*":
|
|
874
|
+
if self.team and hasattr(self.team, 'jinxs_dict') and self.team.jinxs_dict:
|
|
875
|
+
self.jinxs_dict.update(self.team.jinxs_dict)
|
|
876
|
+
else: # If specific jinxs are requested, try to get them from team
|
|
877
|
+
for jinx_name in self.jinxs_spec:
|
|
878
|
+
if self.team and jinx_name in self.team.jinxs_dict:
|
|
879
|
+
self.jinxs_dict[jinx_name] = self.team.jinxs_dict[jinx_name]
|
|
880
|
+
|
|
881
|
+
# Load NPC's own jinxs (if not already covered by team or if specific ones are requested)
|
|
882
|
+
if hasattr(self, 'npc_jinxs_directory') and self.npc_jinxs_directory and os.path.exists(self.npc_jinxs_directory):
|
|
883
|
+
for jinx_obj in load_jinxs_from_directory(self.npc_jinxs_directory):
|
|
884
|
+
if jinx_obj.jinx_name not in self.jinxs_dict: # Only add if not already added from team
|
|
885
|
+
npc_jinxs_raw_list.append(jinx_obj)
|
|
886
|
+
|
|
887
|
+
# If there are raw NPC jinxs to render or team_raw_jinxs available
|
|
888
|
+
if npc_jinxs_raw_list or team_raw_jinxs:
|
|
889
|
+
all_available_raw_jinxs = list(team_raw_jinxs or [])
|
|
890
|
+
all_available_raw_jinxs.extend(npc_jinxs_raw_list)
|
|
891
|
+
|
|
892
|
+
combined_raw_jinxs_dict = {j.jinx_name: j for j in all_available_raw_jinxs}
|
|
893
|
+
|
|
894
|
+
npc_first_pass_jinja_env = Environment(undefined=SilentUndefined)
|
|
895
|
+
|
|
896
|
+
jinx_macro_globals = {}
|
|
897
|
+
for raw_jinx in combined_raw_jinxs_dict.values():
|
|
898
|
+
def create_jinx_callable(jinx_obj_in_closure):
|
|
899
|
+
def callable_jinx(**kwargs):
|
|
900
|
+
temp_jinja_env = Environment(undefined=SilentUndefined)
|
|
901
|
+
rendered_target_steps = []
|
|
902
|
+
for target_step in jinx_obj_in_closure._raw_steps:
|
|
903
|
+
temp_rendered_step = {}
|
|
904
|
+
for k, v in target_step.items():
|
|
905
|
+
if isinstance(v, str):
|
|
906
|
+
try:
|
|
907
|
+
temp_rendered_step[k] = temp_jinja_env.from_string(v).render(**kwargs)
|
|
908
|
+
except Exception as e:
|
|
909
|
+
print(f"Warning: Error in Jinx macro '{jinx_obj_in_closure.jinx_name}' rendering step field '{k}' (NPC first pass): {e}")
|
|
910
|
+
temp_rendered_step[k] = v
|
|
911
|
+
else:
|
|
912
|
+
temp_rendered_step[k] = v
|
|
913
|
+
rendered_target_steps.append(temp_rendered_step)
|
|
914
|
+
return yaml.dump(rendered_target_steps, default_flow_style=False)
|
|
915
|
+
return callable_jinx
|
|
916
|
+
|
|
917
|
+
jinx_macro_globals[raw_jinx.jinx_name] = create_jinx_callable(raw_jinx)
|
|
918
|
+
|
|
919
|
+
npc_first_pass_jinja_env.globals.update(jinx_macro_globals)
|
|
920
|
+
|
|
921
|
+
for raw_npc_jinx in npc_jinxs_raw_list:
|
|
922
|
+
try:
|
|
923
|
+
raw_npc_jinx.render_first_pass(npc_first_pass_jinja_env, jinx_macro_globals)
|
|
924
|
+
self.jinxs_dict[raw_npc_jinx.jinx_name] = raw_npc_jinx
|
|
925
|
+
except Exception as e:
|
|
926
|
+
print(f"Error performing first-pass rendering for NPC Jinx '{raw_npc_jinx.jinx_name}': {e}")
|
|
927
|
+
|
|
928
|
+
print(f"NPC {self.name} loaded {len(self.jinxs_dict)} jinxs.")
|
|
929
|
+
|
|
797
930
|
def _load_npc_kg(self):
|
|
798
931
|
"""Load knowledge graph data for this NPC from database"""
|
|
799
932
|
if not self.command_history:
|
|
@@ -1093,45 +1226,6 @@ class NPC:
|
|
|
1093
1226
|
self.tables = None
|
|
1094
1227
|
self.db_type = None
|
|
1095
1228
|
|
|
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
1229
|
def get_llm_response(self,
|
|
1136
1230
|
request,
|
|
1137
1231
|
jinxs=None,
|
|
@@ -1225,7 +1319,7 @@ class NPC:
|
|
|
1225
1319
|
content = result.get('content', '')[:200] + ('...' if len(result.get('content', '')) > 200 else '')
|
|
1226
1320
|
formatted_results.append(f"[{timestamp}] {content}")
|
|
1227
1321
|
|
|
1228
|
-
return f"Found {len(results)} conversations matching '{query}':\n" + "\n".join(formatted_results)
|
|
1322
|
+
return f"Found {len(results)} conversations matching '{query}'s:\n" + "\n".join(formatted_results)
|
|
1229
1323
|
|
|
1230
1324
|
def search_my_memories(self, query: str, limit: int = 10) -> str:
|
|
1231
1325
|
"""Search through this NPC's knowledge graph memories for relevant facts and concepts"""
|
|
@@ -1621,23 +1715,29 @@ class NPC:
|
|
|
1621
1715
|
"compressed_state": self.compress_planning_state(planning_state),
|
|
1622
1716
|
"summary": f"Completed {len(planning_state['successes'])} tasks for goal: {user_goal}"
|
|
1623
1717
|
}
|
|
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
1718
|
|
|
1719
|
+
def execute_jinx(
|
|
1720
|
+
self,
|
|
1721
|
+
jinx_name,
|
|
1722
|
+
inputs,
|
|
1723
|
+
conversation_id=None,
|
|
1724
|
+
message_id=None,
|
|
1725
|
+
team_name=None,
|
|
1726
|
+
extra_globals=None
|
|
1727
|
+
):
|
|
1628
1728
|
if jinx_name in self.jinxs_dict:
|
|
1629
1729
|
jinx = self.jinxs_dict[jinx_name]
|
|
1630
|
-
elif jinx_name in self.jinxs_dict:
|
|
1631
|
-
jinx = self.jinxs_dict[jinx_name]
|
|
1632
1730
|
else:
|
|
1633
1731
|
return {"error": f"jinx '{jinx_name}' not found"}
|
|
1634
1732
|
|
|
1635
1733
|
result = jinx.execute(
|
|
1636
1734
|
input_values=inputs,
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1735
|
+
npc=self,
|
|
1736
|
+
# messages=messages, # messages should be passed from the calling context if available
|
|
1737
|
+
extra_globals=extra_globals,
|
|
1738
|
+
jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
|
|
1640
1739
|
)
|
|
1740
|
+
|
|
1641
1741
|
if self.db_conn is not None:
|
|
1642
1742
|
self.db_conn.add_jinx_call(
|
|
1643
1743
|
triggering_message_id=message_id,
|
|
@@ -1652,7 +1752,6 @@ class NPC:
|
|
|
1652
1752
|
team_name=team_name,
|
|
1653
1753
|
)
|
|
1654
1754
|
return result
|
|
1655
|
-
|
|
1656
1755
|
def check_llm_command(self,
|
|
1657
1756
|
command,
|
|
1658
1757
|
messages=None,
|
|
@@ -1722,8 +1821,8 @@ class NPC:
|
|
|
1722
1821
|
def to_dict(self):
|
|
1723
1822
|
"""Convert NPC to dictionary representation"""
|
|
1724
1823
|
jinx_rep = []
|
|
1725
|
-
if self.
|
|
1726
|
-
jinx_rep = [ jinx.to_dict()
|
|
1824
|
+
if self.jinxs_dict: # Use jinxs_dict which stores the rendered Jinx objects
|
|
1825
|
+
jinx_rep = [ jinx.to_dict() for jinx in self.jinxs_dict.values()]
|
|
1727
1826
|
return {
|
|
1728
1827
|
"name": self.name,
|
|
1729
1828
|
"primary_directive": self.primary_directive,
|
|
@@ -1731,7 +1830,7 @@ class NPC:
|
|
|
1731
1830
|
"provider": self.provider,
|
|
1732
1831
|
"api_url": self.api_url,
|
|
1733
1832
|
"api_key": self.api_key,
|
|
1734
|
-
"jinxs":
|
|
1833
|
+
"jinxs": self.jinxs_spec, # Save the original spec, not the rendered objects
|
|
1735
1834
|
"use_global_jinxs": self.use_global_jinxs
|
|
1736
1835
|
}
|
|
1737
1836
|
|
|
@@ -1748,10 +1847,10 @@ class NPC:
|
|
|
1748
1847
|
def __str__(self):
|
|
1749
1848
|
"""String representation of NPC"""
|
|
1750
1849
|
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.
|
|
1850
|
+
if self.jinxs_dict:
|
|
1752
1851
|
str_rep += "Jinxs:\n"
|
|
1753
|
-
for
|
|
1754
|
-
str_rep += f" - {
|
|
1852
|
+
for jinx_name in self.jinxs_dict.keys():
|
|
1853
|
+
str_rep += f" - {jinx_name}\n"
|
|
1755
1854
|
else:
|
|
1756
1855
|
str_rep += "No jinxs available.\n"
|
|
1757
1856
|
return str_rep
|
|
@@ -1769,13 +1868,11 @@ class NPC:
|
|
|
1769
1868
|
|
|
1770
1869
|
input_values = extract_jinx_inputs(args, jinx)
|
|
1771
1870
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
1871
|
jinx_output = jinx.execute(
|
|
1776
1872
|
input_values,
|
|
1777
|
-
jinx.jinx_name,
|
|
1778
1873
|
npc=self,
|
|
1874
|
+
messages=messages, # Pass messages to Jinx.execute
|
|
1875
|
+
jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
|
|
1779
1876
|
)
|
|
1780
1877
|
|
|
1781
1878
|
return {"messages": messages, "output": jinx_output}
|
|
@@ -1937,12 +2034,14 @@ class NPC:
|
|
|
1937
2034
|
class Team:
|
|
1938
2035
|
def __init__(self,
|
|
1939
2036
|
team_path=None,
|
|
1940
|
-
npcs=None,
|
|
1941
|
-
forenpc=None,
|
|
1942
|
-
jinxs=None,
|
|
2037
|
+
npcs: Optional[List['NPC']] = None, # Explicitly type hint as list of NPC
|
|
2038
|
+
forenpc: Optional[Union[str, 'NPC']] = None, # Can be name (str) or NPC object
|
|
2039
|
+
jinxs: Optional[List[Union['Jinx', Dict[str, Any]]]] = None, # List of raw Jinx objects or dicts
|
|
1943
2040
|
db_conn=None,
|
|
1944
2041
|
model = None,
|
|
1945
|
-
provider = None
|
|
2042
|
+
provider = None,
|
|
2043
|
+
api_url = None,
|
|
2044
|
+
api_key = None):
|
|
1946
2045
|
"""
|
|
1947
2046
|
Initialize an NPC team from directory or list of NPCs
|
|
1948
2047
|
|
|
@@ -1953,45 +2052,249 @@ class Team:
|
|
|
1953
2052
|
"""
|
|
1954
2053
|
self.model = model
|
|
1955
2054
|
self.provider = provider
|
|
2055
|
+
self.api_url = api_url
|
|
2056
|
+
self.api_key = api_key
|
|
2057
|
+
|
|
2058
|
+
self.npcs: Dict[str, 'NPC'] = {} # Store NPC objects by name
|
|
2059
|
+
self.sub_teams: Dict[str, 'Team'] = {}
|
|
2060
|
+
self.jinxs_dict: Dict[str, 'Jinx'] = {} # This will store first-pass rendered Jinx objects
|
|
2061
|
+
self._raw_jinxs_list: List['Jinx'] = [] # Temporary storage for raw Team-level Jinx objects
|
|
1956
2062
|
|
|
1957
|
-
self.
|
|
1958
|
-
|
|
1959
|
-
self.jinxs_dict = jinxs or {}
|
|
2063
|
+
self.jinja_env_for_first_pass = Environment(undefined=SilentUndefined) # Env for macro expansion
|
|
2064
|
+
|
|
1960
2065
|
self.db_conn = db_conn
|
|
1961
2066
|
self.team_path = os.path.expanduser(team_path) if team_path else None
|
|
1962
2067
|
self.databases = []
|
|
1963
2068
|
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
2069
|
|
|
2070
|
+
self.forenpc: Optional['NPC'] = None # Will be set to an NPC object by end of __init__
|
|
2071
|
+
self.forenpc_name: Optional[str] = None # Temporary storage for name from context (if loaded from .ctx)
|
|
2072
|
+
|
|
1969
2073
|
if team_path:
|
|
1970
2074
|
self.name = os.path.basename(os.path.abspath(team_path))
|
|
1971
|
-
|
|
2075
|
+
self._load_from_directory_and_initialize_forenpc()
|
|
2076
|
+
elif npcs:
|
|
1972
2077
|
self.name = "custom_team"
|
|
2078
|
+
# Add provided NPCs and set their team attribute
|
|
2079
|
+
for npc_obj in npcs:
|
|
2080
|
+
self.npcs[npc_obj.name] = npc_obj
|
|
2081
|
+
npc_obj.team = self # Crucial: set the team for pre-existing NPCs
|
|
2082
|
+
|
|
2083
|
+
if jinxs: # Load raw team-level jinxs if provided
|
|
2084
|
+
for jinx_item in jinxs:
|
|
2085
|
+
if isinstance(jinx_item, Jinx):
|
|
2086
|
+
self._raw_jinxs_list.append(jinx_item)
|
|
2087
|
+
elif isinstance(jinx_item, dict):
|
|
2088
|
+
self._raw_jinxs_list.append(Jinx(jinx_data=jinx_item))
|
|
2089
|
+
# Assuming string jinxs are paths or names to be loaded later if needed.
|
|
2090
|
+
|
|
2091
|
+
self._determine_forenpc_from_provided_npcs(npcs, forenpc)
|
|
2092
|
+
|
|
2093
|
+
else: # No team_path and no npcs list, create a default forenpc
|
|
2094
|
+
self.name = "custom_team"
|
|
2095
|
+
self._create_default_forenpc()
|
|
2096
|
+
|
|
1973
2097
|
self.context = ''
|
|
1974
2098
|
self.shared_context = {
|
|
1975
2099
|
"intermediate_results": {},
|
|
1976
2100
|
"dataframes": {},
|
|
1977
2101
|
"memories": {},
|
|
1978
2102
|
"execution_history": [],
|
|
1979
|
-
"npc_messages": {},
|
|
1980
2103
|
"context":''
|
|
1981
2104
|
}
|
|
1982
|
-
|
|
2105
|
+
|
|
2106
|
+
# Load team context into shared_context after forenpc is determined
|
|
2107
|
+
# This is for teams loaded from directory. For custom/default teams, context is set below.
|
|
1983
2108
|
if team_path:
|
|
1984
|
-
self.
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
self.
|
|
2109
|
+
self._load_team_context_into_shared_context()
|
|
2110
|
+
elif self.forenpc: # For custom teams or default, set basic context if not already set
|
|
2111
|
+
if not self.context: # Only set if context is still empty
|
|
2112
|
+
self.context = f"Team '{self.name}' with forenpc '{self.forenpc.name}'"
|
|
2113
|
+
self.shared_context['context'] = self.context
|
|
1989
2114
|
|
|
1990
|
-
|
|
2115
|
+
# Perform first-pass rendering for team-level jinxs
|
|
2116
|
+
self._perform_first_pass_jinx_rendering()
|
|
2117
|
+
|
|
2118
|
+
# Now, initialize jinxs for all NPCs, as team-level jinxs are ready
|
|
2119
|
+
for npc_obj in self.npcs.values():
|
|
2120
|
+
# Pass the team's raw jinxs to the NPC for its own first-pass rendering
|
|
2121
|
+
npc_obj.initialize_jinxs(team_raw_jinxs=self._raw_jinxs_list)
|
|
1991
2122
|
|
|
1992
2123
|
if db_conn is not None:
|
|
1993
2124
|
init_db_tables()
|
|
1994
2125
|
|
|
2126
|
+
def _load_from_directory_and_initialize_forenpc(self):
|
|
2127
|
+
"""
|
|
2128
|
+
Consolidated method to load NPCs, team context, and resolve the forenpc.
|
|
2129
|
+
Ensures self.npcs is populated and self.forenpc is an NPC object.
|
|
2130
|
+
"""
|
|
2131
|
+
if not os.path.exists(self.team_path):
|
|
2132
|
+
raise ValueError(f"Team directory not found: {self.team_path}")
|
|
2133
|
+
|
|
2134
|
+
# 1. Load all NPCs first (without initializing their jinxs yet)
|
|
2135
|
+
for filename in os.listdir(self.team_path):
|
|
2136
|
+
if filename.endswith(".npc"):
|
|
2137
|
+
npc_path = os.path.join(self.team_path, filename)
|
|
2138
|
+
# Pass 'self' to NPC constructor for team reference
|
|
2139
|
+
# Do NOT pass jinxs=... here, as it will be initialized later
|
|
2140
|
+
npc = NPC(npc_path, db_conn=self.db_conn, team=self)
|
|
2141
|
+
self.npcs[npc.name] = npc
|
|
2142
|
+
|
|
2143
|
+
# 2. Load team context and determine forenpc name (string)
|
|
2144
|
+
self._load_team_context_file() # This populates self.model, self.provider, self.forenpc_name etc.
|
|
2145
|
+
|
|
2146
|
+
# 3. Resolve and set self.forenpc (NPC object)
|
|
2147
|
+
if self.forenpc_name and self.forenpc_name in self.npcs:
|
|
2148
|
+
self.forenpc = self.npcs[self.forenpc_name]
|
|
2149
|
+
elif self.npcs: # Fallback to first NPC if name not found or not specified
|
|
2150
|
+
self.forenpc = list(self.npcs.values())[0]
|
|
2151
|
+
self.forenpc_name = self.forenpc.name # Update forenpc_name for consistency
|
|
2152
|
+
else: # No NPCs loaded, create a default forenpc
|
|
2153
|
+
self._create_default_forenpc()
|
|
2154
|
+
|
|
2155
|
+
# 4. Load raw Jinxs from team directory
|
|
2156
|
+
jinxs_dir = os.path.join(self.team_path, "jinxs")
|
|
2157
|
+
if os.path.exists(jinxs_dir):
|
|
2158
|
+
for jinx_obj in load_jinxs_from_directory(jinxs_dir):
|
|
2159
|
+
self._raw_jinxs_list.append(jinx_obj)
|
|
2160
|
+
|
|
2161
|
+
# 5. Load sub-teams
|
|
2162
|
+
self._load_sub_teams()
|
|
2163
|
+
|
|
2164
|
+
def _load_team_context_file(self) -> Dict[str, Any]:
|
|
2165
|
+
"""Loads team context from .ctx file and updates team attributes."""
|
|
2166
|
+
ctx_data = {}
|
|
2167
|
+
for fname in os.listdir(self.team_path):
|
|
2168
|
+
if fname.endswith('.ctx'):
|
|
2169
|
+
ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
|
|
2170
|
+
if ctx_data is not None:
|
|
2171
|
+
self.model = ctx_data.get('model', self.model)
|
|
2172
|
+
self.provider = ctx_data.get('provider', self.provider)
|
|
2173
|
+
self.api_url = ctx_data.get('api_url', self.api_url)
|
|
2174
|
+
self.env = ctx_data.get('env', self.env if hasattr(self, 'env') else None)
|
|
2175
|
+
self.mcp_servers = ctx_data.get('mcp_servers', [])
|
|
2176
|
+
self.databases = ctx_data.get('databases', [])
|
|
2177
|
+
self.forenpc_name = ctx_data.get('forenpc', self.forenpc_name) # Set forenpc_name (string)
|
|
2178
|
+
return ctx_data
|
|
2179
|
+
return {}
|
|
2180
|
+
|
|
2181
|
+
def _load_team_context_into_shared_context(self):
|
|
2182
|
+
"""Loads team context into shared_context after forenpc is determined."""
|
|
2183
|
+
ctx_data = {}
|
|
2184
|
+
for fname in os.listdir(self.team_path):
|
|
2185
|
+
if fname.endswith('.ctx'):
|
|
2186
|
+
ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
|
|
2187
|
+
if ctx_data is not None:
|
|
2188
|
+
self.context = ctx_data.get('context', '')
|
|
2189
|
+
self.shared_context['context'] = self.context
|
|
2190
|
+
if 'file_patterns' in ctx_data:
|
|
2191
|
+
file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
|
|
2192
|
+
self.shared_context['files'] = file_cache
|
|
2193
|
+
if 'preferences' in ctx_data:
|
|
2194
|
+
self.preferences = ctx_data['preferences']
|
|
2195
|
+
else:
|
|
2196
|
+
self.preferences = []
|
|
2197
|
+
|
|
2198
|
+
for key, item in ctx_data.items():
|
|
2199
|
+
if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env', 'preferences']:
|
|
2200
|
+
self.shared_context[key] = item
|
|
2201
|
+
return # Only load the first .ctx file found
|
|
2202
|
+
|
|
2203
|
+
def _determine_forenpc_from_provided_npcs(self, npcs_list: List['NPC'], forenpc_arg: Optional[Union[str, 'NPC']]):
|
|
2204
|
+
"""Determines self.forenpc when NPCs are provided directly to Team.__init__."""
|
|
2205
|
+
if forenpc_arg:
|
|
2206
|
+
if isinstance(forenpc_arg, NPC):
|
|
2207
|
+
self.forenpc = forenpc_arg
|
|
2208
|
+
self.forenpc_name = forenpc_arg.name
|
|
2209
|
+
elif isinstance(forenpc_arg, str) and forenpc_arg in self.npcs:
|
|
2210
|
+
self.forenpc = self.npcs[forenpc_arg]
|
|
2211
|
+
self.forenpc_name = forenpc_arg
|
|
2212
|
+
else:
|
|
2213
|
+
print(f"Warning: Specified forenpc '{forenpc_arg}' not found among provided NPCs. Falling back to first NPC.")
|
|
2214
|
+
if npcs_list:
|
|
2215
|
+
self.forenpc = npcs_list[0]
|
|
2216
|
+
self.forenpc_name = npcs_list[0].name
|
|
2217
|
+
else:
|
|
2218
|
+
self._create_default_forenpc()
|
|
2219
|
+
elif npcs_list: # Default to first NPC if no forenpc_arg
|
|
2220
|
+
self.forenpc = npcs_list[0]
|
|
2221
|
+
self.forenpc_name = npcs_list[0].name
|
|
2222
|
+
else: # No NPCs provided, create a default forenpc
|
|
2223
|
+
self._create_default_forenpc()
|
|
2224
|
+
|
|
2225
|
+
def _create_default_forenpc(self):
|
|
2226
|
+
"""Creates a default forenpc if none can be determined."""
|
|
2227
|
+
forenpc_model = self.model or 'llama3.2'
|
|
2228
|
+
forenpc_provider = self.provider or 'ollama'
|
|
2229
|
+
forenpc_api_key = self.api_key
|
|
2230
|
+
forenpc_api_url = self.api_url
|
|
2231
|
+
|
|
2232
|
+
default_forenpc = NPC(name='forenpc',
|
|
2233
|
+
primary_directive="""You are the forenpc of the team, coordinating activities
|
|
2234
|
+
between NPCs on the team, verifying that results from
|
|
2235
|
+
NPCs are high quality and can help to adequately answer
|
|
2236
|
+
user requests.""",
|
|
2237
|
+
model=forenpc_model,
|
|
2238
|
+
provider=forenpc_provider,
|
|
2239
|
+
api_key=forenpc_api_key,
|
|
2240
|
+
api_url=forenpc_api_url,
|
|
2241
|
+
team=self # Pass the team to the forenpc
|
|
2242
|
+
)
|
|
2243
|
+
self.forenpc = default_forenpc
|
|
2244
|
+
self.forenpc_name = default_forenpc.name
|
|
2245
|
+
self.npcs[default_forenpc.name] = default_forenpc # Add to team's NPC list
|
|
2246
|
+
|
|
2247
|
+
def _perform_first_pass_jinx_rendering(self):
|
|
2248
|
+
"""
|
|
2249
|
+
Performs the first-pass Jinja rendering on all loaded raw Jinxs.
|
|
2250
|
+
This expands nested Jinx calls but preserves runtime variables.
|
|
2251
|
+
"""
|
|
2252
|
+
# Create Jinja globals for calling other Jinxs as macros
|
|
2253
|
+
jinx_macro_globals = {}
|
|
2254
|
+
for raw_jinx in self._raw_jinxs_list:
|
|
2255
|
+
def create_jinx_callable(jinx_obj_in_closure):
|
|
2256
|
+
def callable_jinx(**kwargs):
|
|
2257
|
+
# This callable will be invoked by the Jinja renderer during the first pass.
|
|
2258
|
+
# It needs to render the target Jinx's *raw* steps with the provided kwargs.
|
|
2259
|
+
temp_jinja_env = Environment(undefined=SilentUndefined)
|
|
2260
|
+
|
|
2261
|
+
rendered_target_steps = []
|
|
2262
|
+
for target_step in jinx_obj_in_closure._raw_steps:
|
|
2263
|
+
temp_rendered_step = {}
|
|
2264
|
+
for k, v in target_step.items():
|
|
2265
|
+
if isinstance(v, str):
|
|
2266
|
+
try:
|
|
2267
|
+
# Render the string, using kwargs as context.
|
|
2268
|
+
# SilentUndefined will ensure {{ var }} that are not in kwargs remain as is.
|
|
2269
|
+
temp_rendered_step[k] = temp_jinja_env.from_string(v).render(**kwargs)
|
|
2270
|
+
except Exception as e:
|
|
2271
|
+
print(f"Warning: Error in Jinx macro '{jinx_obj_in_closure.jinx_name}' rendering step field '{k}' (Team first pass): {e}")
|
|
2272
|
+
temp_rendered_step[k] = v
|
|
2273
|
+
else:
|
|
2274
|
+
temp_rendered_step[k] = v
|
|
2275
|
+
rendered_target_steps.append(temp_rendered_step)
|
|
2276
|
+
|
|
2277
|
+
# Return the YAML string representation of the rendered steps
|
|
2278
|
+
return yaml.dump(rendered_target_steps, default_flow_style=False)
|
|
2279
|
+
return callable_jinx
|
|
2280
|
+
|
|
2281
|
+
jinx_macro_globals[raw_jinx.jinx_name] = create_jinx_callable(raw_jinx)
|
|
2282
|
+
|
|
2283
|
+
self.jinja_env_for_first_pass.globals['jinxs'] = jinx_macro_globals # Make 'jinxs.jinx_name' callable
|
|
2284
|
+
self.jinja_env_for_first_pass.globals.update(jinx_macro_globals) # Also make 'jinx_name' callable directly
|
|
2285
|
+
|
|
2286
|
+
# Now, iterate through the raw Jinxs and perform the first-pass rendering
|
|
2287
|
+
for raw_jinx in self._raw_jinxs_list:
|
|
2288
|
+
try:
|
|
2289
|
+
# Pass the jinx_macro_globals to render_first_pass so it can resolve declarative calls
|
|
2290
|
+
raw_jinx.render_first_pass(self.jinja_env_for_first_pass, jinx_macro_globals)
|
|
2291
|
+
self.jinxs_dict[raw_jinx.jinx_name] = raw_jinx # Store the first-pass rendered Jinx
|
|
2292
|
+
except Exception as e:
|
|
2293
|
+
print(f"Error performing first-pass rendering for Jinx '{raw_jinx.jinx_name}': {e}")
|
|
2294
|
+
|
|
2295
|
+
self._raw_jinxs_list = [] # Clear temporary storage
|
|
2296
|
+
|
|
2297
|
+
|
|
1995
2298
|
def update_context(self, messages: list):
|
|
1996
2299
|
"""Update team context based on recent conversation patterns"""
|
|
1997
2300
|
if len(messages) < 10:
|
|
@@ -2035,77 +2338,6 @@ class Team:
|
|
|
2035
2338
|
with open(team_ctx_path, 'w') as f:
|
|
2036
2339
|
yaml.dump(ctx_data, f)
|
|
2037
2340
|
|
|
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
2341
|
def _load_sub_teams(self):
|
|
2110
2342
|
"""Load sub-teams from subdirectories"""
|
|
2111
2343
|
for item in os.listdir(self.team_path):
|
|
@@ -2119,47 +2351,14 @@ class Team:
|
|
|
2119
2351
|
sub_team = Team(team_path=item_path, db_conn=self.db_conn)
|
|
2120
2352
|
self.sub_teams[item] = sub_team
|
|
2121
2353
|
|
|
2122
|
-
def get_forenpc(self):
|
|
2354
|
+
def get_forenpc(self) -> Optional['NPC']:
|
|
2123
2355
|
"""
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2356
|
+
Returns the forenpc (coordinator) for this team.
|
|
2357
|
+
This method is now primarily for external access, as self.forenpc is set in __init__.
|
|
2127
2358
|
"""
|
|
2128
|
-
|
|
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
|
|
2359
|
+
return self.forenpc
|
|
2161
2360
|
|
|
2162
|
-
def get_npc(self, npc_ref):
|
|
2361
|
+
def get_npc(self, npc_ref: Union[str, 'NPC']) -> Optional['NPC']:
|
|
2163
2362
|
"""Get NPC by name or reference with hierarchical lookup capability"""
|
|
2164
2363
|
if isinstance(npc_ref, NPC):
|
|
2165
2364
|
return npc_ref
|
|
@@ -2181,7 +2380,7 @@ class Team:
|
|
|
2181
2380
|
|
|
2182
2381
|
def orchestrate(self, request):
|
|
2183
2382
|
"""Orchestrate a request through the team"""
|
|
2184
|
-
forenpc = self.get_forenpc()
|
|
2383
|
+
forenpc = self.get_forenpc() # Now guaranteed to be an NPC object
|
|
2185
2384
|
if not forenpc:
|
|
2186
2385
|
return {"error": "No forenpc available to coordinate the team"}
|
|
2187
2386
|
|
|
@@ -2326,7 +2525,7 @@ class Team:
|
|
|
2326
2525
|
"name": self.name,
|
|
2327
2526
|
"npcs": {name: npc.to_dict() for name, npc in self.npcs.items()},
|
|
2328
2527
|
"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.
|
|
2528
|
+
"jinxs": {name: jinx.to_dict() for name, jinx in self.jinxs_dict.items()}, # Use jinxs_dict
|
|
2330
2529
|
"context": getattr(self, 'context', {})
|
|
2331
2530
|
}
|
|
2332
2531
|
|
|
@@ -2350,7 +2549,7 @@ class Team:
|
|
|
2350
2549
|
jinxs_dir = os.path.join(directory, "jinxs")
|
|
2351
2550
|
ensure_dirs_exist(jinxs_dir)
|
|
2352
2551
|
|
|
2353
|
-
for jinx in self.
|
|
2552
|
+
for jinx in self.jinxs_dict.values(): # Use jinxs_dict
|
|
2354
2553
|
jinx.save(jinxs_dir)
|
|
2355
2554
|
|
|
2356
2555
|
for team_name, team in self.sub_teams.items():
|
|
@@ -2435,4 +2634,4 @@ class Team:
|
|
|
2435
2634
|
context_parts.append(content)
|
|
2436
2635
|
context_parts.append("")
|
|
2437
2636
|
|
|
2438
|
-
return "\n".join(context_parts)
|
|
2637
|
+
return "\n".join(context_parts)
|