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