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/llm_funcs.py CHANGED
@@ -1,8 +1,11 @@
1
1
  from jinja2 import Environment, FileSystemLoader, Undefined
2
2
  import json
3
+ import os
3
4
  import PIL
4
- import random
5
+ import random
5
6
  import subprocess
7
+ import copy
8
+ import itertools
6
9
  from typing import List, Dict, Any, Optional, Union
7
10
  from npcpy.npc_sysenv import (
8
11
  print_and_process_stream_with_markdown,
@@ -28,6 +31,7 @@ def gen_image(
28
31
  input_images: List[Union[str, bytes, PIL.Image.Image]] = None,
29
32
  save = False,
30
33
  filename = '',
34
+ api_key: str = None,
31
35
  ):
32
36
  """This function generates an image using the specified provider and model.
33
37
  Args:
@@ -37,6 +41,7 @@ def gen_image(
37
41
  provider (str): The provider to use for generating the image.
38
42
  filename (str): The filename to save the image to.
39
43
  npc (Any): The NPC object.
44
+ api_key (str): The API key for the image generation service.
40
45
  Returns:
41
46
  str: The filename of the saved image.
42
47
  """
@@ -60,6 +65,7 @@ def gen_image(
60
65
  width=width,
61
66
  attachments=input_images,
62
67
  n_images=n_images,
68
+ api_key=api_key,
63
69
 
64
70
  )
65
71
  if save:
@@ -152,10 +158,12 @@ def get_llm_response(
152
158
  messages: List[Dict[str, str]] = None,
153
159
  api_url: str = None,
154
160
  api_key: str = None,
155
- context=None,
161
+ context=None,
156
162
  stream: bool = False,
157
163
  attachments: List[str] = None,
158
164
  include_usage: bool = False,
165
+ n_samples: int = 1,
166
+ matrix: Optional[Dict[str, List[Any]]] = None,
159
167
  **kwargs,
160
168
  ):
161
169
  """This function generates a response using the specified provider and model.
@@ -172,71 +180,132 @@ def get_llm_response(
172
180
  Returns:
173
181
  Any: The response generated by the specified provider and model.
174
182
  """
175
- if model is not None and provider is not None:
176
- pass
177
- elif provider is None and model is not None:
178
- provider = lookup_provider(model)
179
- elif npc is not None:
180
- if npc.provider is not None:
181
- provider = npc.provider
182
- if npc.model is not None:
183
- model = npc.model
184
- if npc.api_url is not None:
185
- api_url = npc.api_url
186
- elif team is not None:
187
- if team.model is not None:
188
- model = team.model
189
- if team.provider is not None:
190
- provider = team.provider
191
- if team.api_url is not None:
192
- api_url = team.api_url
193
-
194
- else:
195
- provider = "ollama"
196
- if images is not None or attachments is not None:
197
- model = "llava:7b"
183
+ import logging
184
+ logger = logging.getLogger("npcpy.llm_funcs")
185
+ logger.debug(f"[get_llm_response] Received {len(messages) if messages else 0} messages, prompt_len={len(prompt) if prompt else 0}")
186
+
187
+ def _resolve_model_provider(npc_override, team_override, model_override, provider_override):
188
+ m = model_override
189
+ p = provider_override
190
+ a_url = api_url
191
+ if m is not None and p is not None:
192
+ pass
193
+ elif p is None and m is not None:
194
+ p = lookup_provider(m)
195
+ elif npc_override is not None:
196
+ if npc_override.provider is not None:
197
+ p = npc_override.provider
198
+ if npc_override.model is not None:
199
+ m = npc_override.model
200
+ if npc_override.api_url is not None:
201
+ a_url = npc_override.api_url
202
+ elif team_override is not None:
203
+ if team_override.model is not None:
204
+ m = team_override.model
205
+ if team_override.provider is not None:
206
+ p = team_override.provider
207
+ if team_override.api_url is not None:
208
+ a_url = team_override.api_url
198
209
  else:
199
- model = "llama3.2"
200
-
201
- if npc is not None:
202
-
203
- system_message = get_system_message(npc, team)
204
- else:
205
- system_message = "You are a helpful assistant."
206
-
210
+ p = "ollama"
211
+ if images is not None or attachments is not None:
212
+ m = "llava:7b"
213
+ else:
214
+ m = "llama3.2"
215
+ return m, p, a_url
216
+
217
+ def _context_suffix(ctx):
218
+ if ctx is not None:
219
+ return f'User Provided Context: {ctx}'
220
+ return ''
221
+
222
+ def _build_messages(base_messages, sys_msg, prompt_text, ctx_suffix):
223
+ msgs = copy.deepcopy(base_messages) if base_messages else []
224
+ if not msgs:
225
+ msgs = [{"role": "system", "content": sys_msg}]
226
+ if prompt_text:
227
+ msgs.append({"role": "user", "content": prompt_text + ctx_suffix})
228
+ elif prompt_text and msgs[-1]["role"] == "user":
229
+ if isinstance(msgs[-1]["content"], str):
230
+ msgs[-1]["content"] += "\n" + prompt_text + ctx_suffix
231
+ else:
232
+ msgs.append({"role": "user", "content": prompt_text + ctx_suffix})
233
+ elif prompt_text:
234
+ msgs.append({"role": "user", "content": prompt_text + ctx_suffix})
235
+ return msgs
236
+
237
+ base_model, base_provider, base_api_url = _resolve_model_provider(npc, team, model, provider)
238
+
239
+ def _run_single(run_model, run_provider, run_npc, run_team, run_context, extra_kwargs):
240
+ system_message = get_system_message(run_npc, run_team) if run_npc is not None else "You are a helpful assistant."
241
+ ctx_suffix = _context_suffix(run_context)
242
+ run_messages = _build_messages(messages, system_message, prompt, ctx_suffix)
243
+ return get_litellm_response(
244
+ prompt + ctx_suffix,
245
+ messages=run_messages,
246
+ model=run_model,
247
+ provider=run_provider,
248
+ api_url=extra_kwargs.get("api_url", base_api_url),
249
+ api_key=api_key,
250
+ images=images,
251
+ attachments=attachments,
252
+ stream=stream,
253
+ include_usage=include_usage,
254
+ **{k: v for k, v in extra_kwargs.items() if k != "api_url"},
255
+ )
207
256
 
208
- if context is not None:
209
- context_str = f'User Provided Context: {context}'
257
+ use_matrix = matrix is not None and len(matrix) > 0
258
+ multi_sample = n_samples and n_samples > 1
259
+
260
+ if not use_matrix and not multi_sample:
261
+ resolved_model, resolved_provider, resolved_api_url = _resolve_model_provider(npc, team, base_model, base_provider)
262
+ return _run_single(resolved_model, resolved_provider, npc, team, context, {"api_url": resolved_api_url, **kwargs})
263
+
264
+ combos = []
265
+ if use_matrix:
266
+ keys = list(matrix.keys())
267
+ values = []
268
+ for key in keys:
269
+ val = matrix[key]
270
+ if isinstance(val, (list, tuple, set)):
271
+ values.append(list(val))
272
+ else:
273
+ values.append([val])
274
+ for combo_values in itertools.product(*values):
275
+ combo = dict(zip(keys, combo_values))
276
+ combos.append(combo)
210
277
  else:
211
- context_str = ''
212
-
213
- if messages is None or len(messages) == 0:
214
- messages = [{"role": "system", "content": system_message}]
215
- if prompt:
216
- messages.append({"role": "user", "content": prompt+context_str})
217
- elif prompt and messages[-1]["role"] == "user":
218
-
219
- if isinstance(messages[-1]["content"], str):
220
- messages[-1]["content"] += "\n" + prompt+context_str
221
- elif prompt:
222
- messages.append({"role": "user",
223
- "content": prompt + context_str})
224
- #import pdb
225
- #pdb.set_trace()
226
- response = get_litellm_response(
227
- prompt + context_str,
228
- messages=messages,
229
- model=model,
230
- provider=provider,
231
- api_url=api_url,
232
- api_key=api_key,
233
- images=images,
234
- attachments=attachments,
235
- stream=stream,
236
- include_usage=include_usage,
237
- **kwargs,
238
- )
239
- return response
278
+ combos.append({})
279
+
280
+ runs = []
281
+ for combo in combos:
282
+ run_model = combo.get("model", base_model)
283
+ run_provider = combo.get("provider", base_provider)
284
+ run_npc = combo.get("npc", npc)
285
+ run_team = combo.get("team", team)
286
+ run_context = combo.get("context", context)
287
+ extra_kwargs = dict(kwargs)
288
+ for k, v in combo.items():
289
+ if k not in {"model", "provider", "npc", "context", "team"}:
290
+ extra_kwargs[k] = v
291
+
292
+ resolved_model, resolved_provider, resolved_api_url = _resolve_model_provider(run_npc, run_team, run_model, run_provider)
293
+ for sample_idx in range(max(1, n_samples or 1)):
294
+ resp = _run_single(resolved_model, resolved_provider, run_npc, run_team, run_context, {"api_url": resolved_api_url, **extra_kwargs})
295
+ runs.append({
296
+ "response": resp.get("response") if isinstance(resp, dict) else resp,
297
+ "raw": resp,
298
+ "combo": combo,
299
+ "sample_index": sample_idx,
300
+ })
301
+
302
+ aggregated = {
303
+ "response": runs[0]["response"] if runs else None,
304
+ "runs": runs,
305
+ }
306
+ if runs and isinstance(runs[0]["raw"], dict) and "messages" in runs[0]["raw"]:
307
+ aggregated["messages"] = runs[0]["raw"].get("messages")
308
+ return aggregated
240
309
 
241
310
 
242
311
 
@@ -380,259 +449,6 @@ def execute_llm_command(
380
449
  "output": "Max attempts reached. Unable to execute the command successfully.",
381
450
  }
382
451
 
383
- # --- START OF CORRECTED handle_jinx_call ---
384
- def handle_jinx_call(
385
- command: str,
386
- jinx_name: str,
387
- model: str = None,
388
- provider: str = None,
389
- messages: List[Dict[str, str]] = None,
390
- npc: Any = None,
391
- team: Any = None,
392
- stream=False,
393
- n_attempts=3,
394
- attempt=0,
395
- context=None,
396
- extra_globals=None,
397
- **kwargs
398
- ) -> Union[str, Dict[str, Any]]:
399
- """This function handles a jinx call.
400
- Args:
401
- command (str): The command.
402
- jinx_name (str): The jinx name.
403
- Keyword Args:
404
- model (str): The model to use for handling the jinx call.
405
- provider (str): The provider to use for handling the jinx call.
406
- messages (List[Dict[str, str]]): The list of messages.
407
- npc (Any): The NPC object.
408
- Returns:
409
- Union[str, Dict[str, Any]]: The result of handling
410
- the jinx call.
411
-
412
- """
413
- if npc is None and team is None:
414
- return f"No jinxs are available. "
415
- else:
416
- jinx = None
417
- if npc and hasattr(npc, 'jinxs_dict') and jinx_name in npc.jinxs_dict:
418
- jinx = npc.jinxs_dict[jinx_name]
419
- elif team and hasattr(team, 'jinxs_dict') and jinx_name in team.jinxs_dict:
420
- jinx = team.jinxs_dict[jinx_name]
421
-
422
- if not jinx:
423
- print(f"Jinx {jinx_name} not available")
424
- if attempt < n_attempts:
425
- print(f"attempt {attempt+1} to generate jinx name failed, trying again")
426
- return check_llm_command(
427
- f'''
428
- In the previous attempt, the jinx name was: {jinx_name}.
429
-
430
- That jinx was not available, only select those that are available.
431
-
432
- If there are no available jinxs choose an alternative action. Do not invoke the jinx action.
433
-
434
-
435
- Here was the original command: BEGIN ORIGINAL COMMAND
436
- '''+ command +' END ORIGINAL COMMAND',
437
- model=model,
438
- provider=provider,
439
- messages=messages,
440
- npc=npc,
441
- team=team,
442
- stream=stream,
443
- context=context
444
- )
445
- return {
446
- "output": f"Incorrect jinx name supplied and n_attempts reached.",
447
- "messages": messages,
448
- }
449
-
450
- render_markdown(f"jinx found: {jinx.jinx_name}")
451
-
452
- # This jinja_env is for parsing the Jinx's *inputs* from the LLM response, not for Jinx.execute's second pass.
453
- local_jinja_env_for_input_parsing = Environment(loader=FileSystemLoader("."), undefined=Undefined)
454
-
455
- example_format = {}
456
- for inp in jinx.inputs:
457
- if isinstance(inp, str):
458
- example_format[inp] = f"<value for {inp}>"
459
- elif isinstance(inp, dict):
460
- key = list(inp.keys())[0]
461
- example_format[key] = f"<value for {key}>"
462
-
463
- json_format_str = json.dumps(example_format, indent=4)
464
-
465
-
466
- prompt = f"""
467
- The user wants to use the jinx '{jinx_name}' with the following request:
468
- '{command}'"""
469
-
470
-
471
- prompt += f'Here were the previous 5 messages in the conversation: {messages[-5:]}'
472
-
473
-
474
- prompt+=f"""Here is the jinx file:
475
- ```
476
- {jinx.to_dict()}
477
- ```
478
-
479
- Please determine the required inputs for the jinx as a JSON object.
480
-
481
- They must be exactly as they are named in the jinx.
482
- For example, if the jinx has three inputs, you should respond with a list of three values that will pass for those args.
483
-
484
- If the jinx requires a file path, you must include an absolute path to the file including an extension.
485
- If the jinx requires code to be generated, you must generate it exactly according to the instructions.
486
- Your inputs must satisfy the jinx's requirements.
487
-
488
-
489
-
490
-
491
- Return only the JSON object without any markdown formatting.
492
-
493
- The format of the JSON object is:
494
-
495
- """+"{"+json_format_str+"}"
496
-
497
- if npc and hasattr(npc, "shared_context"):
498
- if npc.shared_context.get("dataframes"):
499
- context_info = "\nAvailable dataframes:\n"
500
- for df_name in npc.shared_context["dataframes"].keys():
501
- context_info += f"- {df_name}\n"
502
- prompt += f"""Here is contextual info that may affect your choice: {context_info}
503
- """
504
- response = get_llm_response(
505
- prompt,
506
- format="json",
507
- model=model,
508
- provider=provider,
509
- messages=messages[-10:],
510
- npc=npc,
511
- context=context
512
- )
513
-
514
- try:
515
- response_text = response.get("response", "{}")
516
- if isinstance(response_text, str):
517
- response_text = (
518
- response_text.replace("```json", "").replace("```", "").strip()
519
- )
520
-
521
-
522
- if isinstance(response_text, dict):
523
- input_values = response_text
524
- else:
525
- input_values = json.loads(response_text)
526
-
527
- except json.JSONDecodeError as e:
528
- print(f"Error decoding input values: {e}. Raw response: {response}")
529
- return f"Error extracting inputs for jinx '{jinx_name}'"
530
-
531
- required_inputs = jinx.inputs
532
- missing_inputs = []
533
- for inp in required_inputs:
534
- if not isinstance(inp, dict):
535
-
536
- if inp not in input_values or input_values[inp] == "":
537
- missing_inputs.append(inp)
538
- if len(missing_inputs) > 0:
539
-
540
- if attempt < n_attempts:
541
- print(f"attempt {attempt+1} to generate inputs failed, trying again")
542
- print("missing inputs", missing_inputs)
543
- print("llm response", response)
544
- print("input values", input_values)
545
- return handle_jinx_call(
546
- command +' . In the previous attempt, the inputs were: ' + str(input_values) + ' and the missing inputs were: ' + str(missing_inputs) +' . Please ensure to not make this mistake again.',
547
- jinx_name,
548
- model=model,
549
- provider=provider,
550
- messages=messages,
551
- npc=npc,
552
- team=team,
553
- stream=stream,
554
- attempt=attempt + 1,
555
- n_attempts=n_attempts,
556
- context=context
557
- )
558
- return {
559
- "output": f"Missing inputs for jinx '{jinx_name}': {missing_inputs}",
560
- "messages": messages,
561
- }
562
-
563
-
564
- render_markdown( "\n".join(['\n - ' + str(key) + ': ' +str(val) for key, val in input_values.items()]))
565
-
566
- # Initialize jinx_output before the try block to prevent UnboundLocalError
567
- jinx_output = {"output": "Jinx execution did not complete."}
568
-
569
- try:
570
- # --- CRITICAL FIX HERE ---
571
- # Pass arguments as keyword arguments to avoid positional confusion
572
- # Use npc.jinja_env for the second-pass rendering
573
- jinx_output = jinx.execute(
574
- input_values=input_values,
575
- npc=npc, # This is the orchestrating NPC
576
- messages=messages,
577
- extra_globals=extra_globals,
578
- jinja_env=npc.jinja_env if npc else (team.forenpc.jinja_env if team and team.forenpc else None) # Use NPC's or Team's forenpc's jinja_env
579
- )
580
- # Ensure jinx_output is a dict with an 'output' key
581
- if jinx_output is None:
582
- jinx_output = {"output": "Jinx executed, but returned no explicit output."}
583
- elif not isinstance(jinx_output, dict):
584
- jinx_output = {"output": str(jinx_output)}
585
-
586
- except Exception as e:
587
- print(f"An error occurred while executing the jinx: {e}")
588
- print(f"trying again, attempt {attempt+1}")
589
- print('command', command)
590
- if attempt < n_attempts:
591
- # Recursively call handle_jinx_call for retry
592
- jinx_output = handle_jinx_call(
593
- command,
594
- jinx_name,
595
- model=model,
596
- provider=provider,
597
- messages=messages,
598
- npc=npc,
599
- team=team,
600
- stream=stream,
601
- attempt=attempt + 1,
602
- n_attempts=n_attempts,
603
- context=f""" \n \n \n "jinx failed: {e} \n \n \n here was the previous attempt: {input_values}""",
604
- extra_globals=extra_globals
605
- )
606
- else:
607
- # If max attempts reached, set a clear error output
608
- jinx_output = {"output": f"Jinx '{jinx_name}' failed after {n_attempts} attempts: {e}", "error": True}
609
-
610
-
611
- if not stream and len(messages) > 0 :
612
- render_markdown(f""" ## jinx OUTPUT FROM CALLING {jinx_name} \n \n output:{jinx_output.get('output', 'No output.')}""" )
613
- response = get_llm_response(f"""
614
- The user had the following request: {command}.
615
- Here were the jinx outputs from calling {jinx_name}: {jinx_output.get('output', '')}
616
-
617
- Given the jinx outputs and the user request, please format a simple answer that
618
- provides the answer without requiring the user to carry out any further steps.
619
- """,
620
- model=model,
621
- provider=provider,
622
- npc=npc,
623
- messages=messages[-10:],
624
- context=context,
625
- stream=stream,
626
- )
627
- messages = response['messages']
628
- response = response.get("response", {})
629
- return {'messages':messages, 'output':response}
630
-
631
- return {'messages': messages, 'output': jinx_output.get('output', 'No output.')} # Ensure 'output' key exists
632
-
633
- # --- END OF CORRECTED handle_jinx_call ---
634
-
635
-
636
452
  def handle_request_input(
637
453
  context: str,
638
454
  model: str ,
@@ -677,53 +493,61 @@ def handle_request_input(
677
493
 
678
494
 
679
495
 
680
- def jinx_handler(command, extracted_data, **kwargs):
681
- return handle_jinx_call(
682
- command,
683
- extracted_data.get('jinx_name'),
684
- model=kwargs.get('model'),
685
- provider=kwargs.get('provider'),
686
- api_url=kwargs.get('api_url'),
687
- api_key=kwargs.get('api_key'),
688
- messages=kwargs.get('messages'),
689
- npc=kwargs.get('npc'),
690
- team=kwargs.get('team'),
691
- stream=kwargs.get('stream'),
692
- context=kwargs.get('context'),
693
- extra_globals=kwargs.get('extra_globals')
694
- )
496
+ def _get_jinxs(npc, team):
497
+ """Get available jinxs from npc and team."""
498
+ jinxs = {}
499
+ if npc and hasattr(npc, 'jinxs_dict'):
500
+ jinxs.update(npc.jinxs_dict)
501
+ if team and hasattr(team, 'jinxs_dict'):
502
+ jinxs.update(team.jinxs_dict)
503
+ return jinxs
504
+
505
+
506
+ def _jinxs_to_tools(jinxs):
507
+ """Convert jinxs to OpenAI-style tool definitions."""
508
+ return [jinx.to_tool_def() for jinx in jinxs.values()]
509
+
510
+
511
+ def _execute_jinx(jinx, inputs, npc, team, messages, extra_globals):
512
+ """Execute a jinx and return output."""
513
+ try:
514
+ jinja_env = None
515
+ if npc and hasattr(npc, 'jinja_env'):
516
+ jinja_env = npc.jinja_env
517
+ elif team and hasattr(team, 'forenpc') and team.forenpc:
518
+ jinja_env = getattr(team.forenpc, 'jinja_env', None)
519
+
520
+ # Merge with default values from jinx definition
521
+ full_inputs = {}
522
+ for inp in getattr(jinx, 'inputs', []):
523
+ if isinstance(inp, dict):
524
+ key = list(inp.keys())[0]
525
+ default_val = inp[key]
526
+ # Expand paths
527
+ if isinstance(default_val, str) and default_val.startswith("~"):
528
+ default_val = os.path.expanduser(default_val)
529
+ full_inputs[key] = default_val
530
+ else:
531
+ full_inputs[inp] = ""
532
+ # Override with provided inputs
533
+ full_inputs.update(inputs or {})
534
+
535
+ result = jinx.execute(
536
+ input_values=full_inputs,
537
+ npc=npc,
538
+ messages=messages,
539
+ extra_globals=extra_globals,
540
+ jinja_env=jinja_env
541
+ )
542
+ if result is None:
543
+ return "Executed with no output."
544
+ if not isinstance(result, dict):
545
+ return str(result)
546
+ return result.get("output", str(result))
547
+ except Exception as e:
548
+ return f"Error: {e}"
695
549
 
696
- def answer_handler(command, extracted_data, **kwargs):
697
550
 
698
- response = get_llm_response(
699
- f"""
700
-
701
- Here is the user question: {command}
702
-
703
-
704
- Do not needlessly reference the user's files or provided context.
705
-
706
- Simply provide the answer to the user's question. Avoid
707
- appearing zany or unnecessarily forthcoming about the fact that you have received such information. You know it
708
- and the user knows it. there is no need to constantly mention the facts that are aware to both.
709
-
710
- Your previous commnets on this topic: {extracted_data.get('explanation', '')}
711
-
712
- """,
713
- model=kwargs.get('model'),
714
- provider=kwargs.get('provider'),
715
- api_url=kwargs.get('api_url'),
716
- api_key=kwargs.get('api_key'),
717
- messages=kwargs.get('messages',)[-10:],
718
- npc=kwargs.get('npc'),
719
- team=kwargs.get('team'),
720
- stream=kwargs.get('stream', False),
721
- images=kwargs.get('images'),
722
- context=kwargs.get('context')
723
- )
724
-
725
- return response
726
-
727
551
  def check_llm_command(
728
552
  command: str,
729
553
  model: str = None,
@@ -736,510 +560,332 @@ def check_llm_command(
736
560
  images: list = None,
737
561
  stream=False,
738
562
  context=None,
739
- actions: Dict[str, Dict] = None,
563
+ actions: Dict[str, Dict] = None, # backwards compat, ignored
740
564
  extra_globals=None,
565
+ max_iterations: int = 5,
566
+ jinxs: Dict = None,
741
567
  ):
742
- """This function checks an LLM command and returns sequences of steps with parallel actions."""
568
+ """
569
+ Simple agent loop: try tool calling first, fall back to ReAct if unsupported.
570
+ """
743
571
  if messages is None:
744
572
  messages = []
745
573
 
746
- if actions is None:
747
- actions = DEFAULT_ACTION_SPACE.copy()
748
- exec = execute_multi_step_plan(
749
- command=command,
750
- model=model,
751
- provider=provider,
752
- api_url=api_url,
753
- api_key=api_key,
754
- npc=npc,
755
- team=team,
756
- messages=messages,
757
- images=images,
758
- stream=stream,
759
- context=context,
760
- actions=actions,
761
- extra_globals=extra_globals,
762
-
763
- )
764
- return exec
765
-
766
-
767
-
768
-
769
- def jinx_context_filler(npc, team):
770
- """
771
- Generate context information about available jinxs for NPCs and teams.
772
-
773
- Args:
774
- npc: The NPC object
775
- team: The team object
776
-
777
- Returns:
778
- str: Formatted string containing jinx information and usage guidelines
779
- """
780
-
781
- npc_jinxs = "\nNPC Jinxs:\n" + (
782
- "\n".join(
783
- f"- {name}: {jinx.description}"
784
- for name, jinx in getattr(npc, "jinxs_dict", {}).items()
785
- )
786
- if getattr(npc, "jinxs_dict", None)
787
- else ''
788
- )
789
-
790
-
791
- team_jinxs = "\n\nTeam Jinxs:\n" + (
792
- "\n".join(
793
- f"- {name}: {jinx.description}"
794
- for name, jinx in getattr(team, "jinxs_dict", {}).items()
574
+ # Log incoming messages
575
+ import logging
576
+ logger = logging.getLogger("npcpy.llm_funcs")
577
+ logger.debug(f"[check_llm_command] Received {len(messages)} messages")
578
+ for i, msg in enumerate(messages[-5:]): # Log last 5 messages
579
+ role = msg.get('role', 'unknown')
580
+ content = msg.get('content', '')
581
+ content_preview = content[:100] if isinstance(content, str) else str(type(content))
582
+ logger.debug(f" [{i}] role={role}, content_preview={content_preview}...")
583
+
584
+ total_usage = {"input_tokens": 0, "output_tokens": 0}
585
+ # Use provided jinxs or get from npc/team
586
+ if jinxs is None:
587
+ jinxs = _get_jinxs(npc, team)
588
+ tools = _jinxs_to_tools(jinxs) if jinxs else None
589
+
590
+ # Keep full message history, only truncate for API calls to reduce tokens
591
+ full_messages = messages.copy() if messages else []
592
+ logger.debug(f"[check_llm_command] full_messages initialized with {len(full_messages)} messages")
593
+
594
+ # Try with native tool calling first
595
+
596
+ try:
597
+ response = get_llm_response(
598
+ command,
599
+ model=model,
600
+ provider=provider,
601
+ api_url=api_url,
602
+ api_key=api_key,
603
+ messages=messages[-10:], # Truncate for API call only
604
+ npc=npc,
605
+ team=team,
606
+ images=images,
607
+ stream=stream,
608
+ context=context,
609
+ tools=tools,
795
610
  )
796
- if team and getattr(team, "jinxs_dict", None)
797
- else ''
798
- )
799
-
800
-
801
- usage_guidelines = """
802
- Use jinxs when appropriate. For example:
803
-
804
- - If you are asked about something up-to-date or dynamic (e.g., latest exchange rates)
805
- - If the user asks you to read or edit a file
806
- - If the user asks for code that should be executed
807
- - If the user requests to open, search, download or scrape, which involve actual system or web actions
808
- - If they request a screenshot, audio, or image manipulation
809
- - Situations requiring file parsing (e.g., CSV or JSON loading)
810
- - Scripted workflows or pipelines, e.g., generate a chart, fetch data, summarize from source, etc.
811
-
812
- You MUST use a jinx if the request directly refers to a tool the AI cannot handle directly (e.g., 'run', 'open', 'search', etc).
813
-
814
- You must NOT use a jinx if:
815
- - The user asks you to write them a story (unless they specify saving it to a file)
816
- - To answer simple questions
817
- - To determine general information that does not require up-to-date details
818
- - To answer questions that can be answered with existing knowledge
819
-
820
- To invoke a jinx, return the action 'invoke_jinx' along with the jinx specific name.
821
- An example for a jinx-specific return would be:
822
- {
823
- "action": "invoke_jinx",
824
- "jinx_name": "file_reader",
825
- "explanation": "Read the contents of <full_filename_path_from_user_request> and <detailed explanation of how to accomplish the problem outlined in the request>."
826
- }
827
-
828
- Do not use the jinx names as the action keys. You must use the action 'invoke_jinx' to invoke a jinx!
829
- Do not invent jinx names. Use only those provided.
830
-
831
- Here are the currently available jinxs:"""
832
-
833
-
834
-
835
-
836
- if not npc_jinxs and not team_jinxs:
837
- return "No jinxs are available."
838
- else:
839
- output = usage_guidelines
840
- if npc_jinxs:
841
- output += npc_jinxs
842
- if team_jinxs:
843
- output += team_jinxs
844
- return output
845
-
846
-
847
-
848
- DEFAULT_ACTION_SPACE = {
849
- "invoke_jinx": {
850
- "description": "Invoke a jinx (jinja-template execution script)",
851
- "handler": jinx_handler,
852
- "context": lambda npc=None, team=None, **_: jinx_context_filler(npc, team),
853
- "output_keys": {
854
- "jinx_name": {
855
- "description": "The name of the jinx to invoke. must be from the provided list verbatim",
856
- "type": "string"
857
- }
858
- }
859
- },
860
- "answer": {
861
- "description": "Provide a direct informative answer",
862
- "handler": answer_handler,
863
- "context": """For general questions, use existing knowledge. For most queries a single action to answer a question will be sufficient.
864
- e.g.
865
-
866
- {
867
- "actions": [
868
- {
869
- "action": "answer",
870
- "explanation": "Provide a direct answer to the user's question based on existing knowledge."
871
-
872
-
611
+ except Exception as e:
612
+ print(colored(f"[check_llm_command] EXCEPTION in get_llm_response: {type(e).__name__}: {e}", "red"))
613
+ return {
614
+ "messages": full_messages,
615
+ "output": f"LLM call failed: {e}",
616
+ "error": str(e),
617
+ "usage": total_usage,
873
618
  }
874
- ]
875
- }
876
-
877
- This should be preferred for more than half of requests. Do not overcomplicate the process.
878
- Starting dialogue is usually more useful than using tools willynilly. Think carefully about
879
- the user's intent and use this action as an opportunity to clear up potential ambiguities before
880
- proceeding to more complex actions.
881
- For example, if a user requests to write a story,
882
- it is better to respond with 'answer' and to write them a story rather than to invoke some tool.
883
- Indeed, it might be even better to respond and to request clarification about what other elements they would liek to specify with the story.
884
- Natural language is highly ambiguous and it is important to establish common ground and priorities before proceeding to more complex actions.
885
-
886
- """,
887
- "output_keys": {}
888
- }
889
- }
890
- def plan_multi_step_actions(
891
- command: str,
892
- actions: Dict[str, Dict],
893
- npc: Any = None,
894
- team: Any = None,
895
- model: str = None,
896
- provider: str = None,
897
- api_url: str = None,
898
- api_key: str = None,
899
- context: str = None,
900
- messages: List[Dict[str, str]] = None,
901
-
902
619
 
903
- ):
904
- """
905
- Analyzes the user's command and creates a complete, sequential plan of actions
906
- by dynamically building a prompt from the provided action space.
907
- """
908
-
909
-
910
- prompt = f"""
911
- Analyze the user's request: "{command}"
620
+ if response.get("error"):
621
+ logger.warning(f"[check_llm_command] Error in response: {response.get('error')}")
622
+
623
+ if response.get("usage"):
624
+ total_usage["input_tokens"] += response["usage"].get("input_tokens", 0)
625
+ total_usage["output_tokens"] += response["usage"].get("output_tokens", 0)
626
+
627
+ # Check if tool calls were made
628
+ tool_calls = response.get("tool_calls", [])
629
+ if not tool_calls:
630
+ # Direct answer - append user message to full messages
631
+ full_messages.append({"role": "user", "content": command})
632
+ assistant_response = response.get("response", "")
633
+ # Only append assistant message if it's a string (not a stream)
634
+ # For streaming, the caller (process_result) handles appending after consumption
635
+ if assistant_response and isinstance(assistant_response, str):
636
+ full_messages.append({"role": "assistant", "content": assistant_response})
637
+ logger.debug(f"[check_llm_command] No tool calls - returning {len(full_messages)} messages")
638
+ return {
639
+ "messages": full_messages,
640
+ "output": assistant_response,
641
+ "usage": total_usage,
642
+ }
912
643
 
913
- Your task is to create a complete, sequential JSON plan to fulfill the entire request.
914
- Use the following context about available actions and tools to construct the plan.
644
+ # Helper to serialize tool_calls for message history
645
+ def _serialize_tool_calls(tcs):
646
+ """Convert tool_call objects to dicts for message history."""
647
+ serialized = []
648
+ for tc in tcs:
649
+ if hasattr(tc, 'id'):
650
+ # It's an object, convert to dict
651
+ func = tc.function
652
+ serialized.append({
653
+ "id": tc.id,
654
+ "type": "function",
655
+ "function": {
656
+ "name": func.name if hasattr(func, 'name') else func.get('name'),
657
+ "arguments": func.arguments if hasattr(func, 'arguments') else func.get('arguments', '{}')
658
+ }
659
+ })
660
+ else:
661
+ # Already a dict
662
+ serialized.append(tc)
663
+ return serialized
664
+
665
+ # Execute tool calls in a loop - start with full messages
666
+ full_messages.append({"role": "user", "content": command})
667
+ # Add assistant message with tool_calls before executing them
668
+ assistant_msg = {"role": "assistant", "content": response.get("response", "")}
669
+ if tool_calls:
670
+ assistant_msg["tool_calls"] = _serialize_tool_calls(tool_calls)
671
+ full_messages.append(assistant_msg)
672
+ current_messages = full_messages
673
+ logger.debug(f"[check_llm_command] Tool calls detected - current_messages has {len(current_messages)} messages")
674
+ for iteration in range(max_iterations):
675
+ for tc in tool_calls:
676
+ # Handle both dict and object formats
677
+ if hasattr(tc, 'function'):
678
+ func = tc.function
679
+ jinx_name = func.name if hasattr(func, 'name') else func.get('name')
680
+ args_str = func.arguments if hasattr(func, 'arguments') else func.get('arguments', '{}')
681
+ tc_id = tc.id if hasattr(tc, 'id') else tc.get('id', '')
682
+ else:
683
+ func = tc.get("function", {})
684
+ jinx_name = func.get("name")
685
+ args_str = func.get("arguments", "{}")
686
+ tc_id = tc.get("id", "")
915
687
 
916
- """
917
- if messages == None:
918
- messages = list()
919
- for action_name, action_info in actions.items():
920
- ctx = action_info.get("context")
921
- if callable(ctx):
922
688
  try:
923
-
924
- ctx = ctx(npc=npc, team=team)
925
- except Exception as e:
926
- print( actions)
927
- print(f"[WARN] Failed to render context for action '{action_name}': {e}")
928
- ctx = None
929
-
930
- if ctx:
931
- prompt += f"\n--- Context for action '{action_name}' ---\n{ctx}\n"
932
- if len(messages) >0:
933
- prompt += f'Here were the previous 5 messages in the conversation: {messages[-5:]}'
934
-
935
- prompt += f"""
936
- --- Instructions ---
937
- Based on the user's request and the context provided above, create a plan.
938
-
939
- The plan must be a JSON object with a single key, "actions". Each action must include:
940
- - "action": The name of the action to take.
941
- - "explanation": A clear description of the goal for this specific step.
942
-
943
- An Example Plan might look like this depending on the available actions:
944
- """ + """
945
- {
946
- "actions": [
947
- {
948
- "action": "<action_name_1>",
949
- "<action_specific_key_1..>": "<action_specific_value_1>",
950
- <...> : ...,
951
- "explanation": "Identify the current CEO of Microsoft."
952
- },
953
- {
954
- "action": "<action_name_2>",
955
- "<action_specific_key_1..>": "<action_specific_value_1>",
956
- "explanation": "Find the <action-specific> information identified in the previous step."
957
- }
958
- ]
959
- }
960
-
961
- The plans should mostly be 1-2 actions and usually never more than 3 actions at a time.
962
- Interactivity is important, unless a user specifies a usage of a specific action, it is generally best to
963
- assume just to respond in the simplest way possible rather than trying to assume certain actions have been requested.
964
-
965
- """+f"""
966
- Now, create the plan for the user's query: "{command}"
967
- Respond ONLY with the plan.
968
- """
969
-
970
- action_response = get_llm_response(
971
- prompt,
972
- model=model,
973
- provider=provider,
974
- api_url=api_url,
975
- api_key=api_key,
976
- npc=npc,
977
- team=team,
978
- format="json",
979
- messages=[],
980
- context=context,
981
- )
982
- response_content = action_response.get("response", {})
983
-
984
-
985
- return response_content.get("actions", [])
986
-
987
- def execute_multi_step_plan(
988
- command: str,
989
- model: str = None,
990
- provider: str = None,
991
- api_url: str = None,
992
- api_key: str = None,
993
- npc: Any = None,
994
- team: Any = None,
995
- messages: List[Dict[str, str]] = None,
996
- images: list = None,
997
- stream=False,
998
- context=None,
999
- actions: Dict[str, Dict] = None,
1000
- extra_globals=None,
1001
- **kwargs,
1002
- ):
1003
- """
1004
- Creates a comprehensive plan and executes it sequentially, passing context
1005
- between steps for adaptive behavior.
1006
- """
1007
-
1008
-
1009
- planned_actions = plan_multi_step_actions(
1010
- command=command,
1011
- actions=actions,
1012
- npc=npc,
1013
- model=model,
1014
- provider=provider,
1015
- api_url=api_url,
1016
- api_key=api_key,
1017
- context=context,
1018
- messages=messages,
1019
- team=team,
1020
-
1021
-
1022
- )
1023
-
1024
- if not planned_actions:
1025
- print("Could not generate a multi-step plan. Answering directly.")
1026
- result = answer_handler(command=command,
1027
- extracted_data={"explanation": "Answering the user's query directly."},
1028
- model=model,
1029
- provider=provider,
1030
- api_url=api_url,
1031
- api_key=api_key,
1032
- messages=messages,
1033
- npc=npc,
1034
- stream=stream,
1035
- team = team,
1036
- images=images,
1037
- context=context
1038
- )
1039
- return {"messages": result.get('messages',
1040
- messages),
1041
- "output": result.get('response')}
1042
-
1043
-
1044
- step_outputs = []
1045
- current_messages = messages.copy()
1046
- render_markdown(f"### Plan for Command: {command[:100]}")
1047
- for action in planned_actions:
1048
- step_info = json.dumps({'action': action.get('action', ''),
1049
- 'explanation': str(action.get('explanation',''))[0:10]+'...'})
1050
- render_markdown(f'- {step_info}')
1051
-
689
+ inputs = json.loads(args_str) if isinstance(args_str, str) else args_str
690
+ except:
691
+ inputs = {}
692
+
693
+ if jinx_name in jinxs:
694
+ try:
695
+ from termcolor import colored
696
+ print(colored(f" ⚡ {jinx_name}", "cyan"), end="", flush=True)
697
+ except:
698
+ pass
699
+ output = _execute_jinx(jinxs[jinx_name], inputs, npc, team, current_messages, extra_globals)
700
+ try:
701
+ print(colored(" ✓", "green"), flush=True)
702
+ except:
703
+ pass
704
+
705
+ # Add tool result to messages
706
+ # Include name for Gemini compatibility
707
+ current_messages.append({
708
+ "role": "tool",
709
+ "tool_call_id": tc_id,
710
+ "name": jinx_name,
711
+ "content": str(output)
712
+ })
713
+
714
+ # Get next response - truncate carefully to not orphan tool responses
715
+ # Find a safe truncation point that doesn't split tool_call/tool_response pairs
716
+ truncated = current_messages
717
+ if len(current_messages) > 15:
718
+ # Start from -15 and walk back to find a user message or start
719
+ start_idx = len(current_messages) - 15
720
+ while start_idx > 0:
721
+ msg = current_messages[start_idx]
722
+ # Don't start on a tool response - would orphan it
723
+ if msg.get("role") == "tool":
724
+ start_idx -= 1
725
+ # Don't start on assistant with tool_calls unless next is tool response
726
+ elif msg.get("role") == "assistant" and msg.get("tool_calls"):
727
+ start_idx -= 1
728
+ else:
729
+ break
730
+ truncated = current_messages[start_idx:]
1052
731
 
1053
-
1054
- for i, action_data in enumerate(planned_actions):
1055
- render_markdown(f"--- Executing Step {i + 1} of {len(planned_actions)} ---")
1056
- action_name = action_data["action"]
1057
-
1058
-
1059
732
  try:
1060
- handler = actions[action_name]["handler"]
1061
-
1062
-
1063
-
1064
- step_context = f"Context from previous steps: {json.dumps(step_outputs)}" if step_outputs else ""
1065
- render_markdown(
1066
- f"- Executing Action: {action_name} \n- Explanation: {action_data.get('explanation')}\n "
1067
- )
1068
-
1069
- result = handler(
1070
- command=command,
1071
- extracted_data=action_data,
733
+ response = get_llm_response(
734
+ "", # continuation
1072
735
  model=model,
1073
- provider=provider,
736
+ provider=provider,
1074
737
  api_url=api_url,
1075
- api_key=api_key,
1076
- messages=current_messages,
738
+ api_key=api_key,
739
+ messages=truncated,
1077
740
  npc=npc,
1078
741
  team=team,
1079
- stream=stream,
1080
- context=context+step_context,
1081
- images=images,
1082
- extra_globals=extra_globals
1083
- )
1084
- except KeyError as e:
1085
-
1086
- return execute_multi_step_plan(
1087
- command=command + 'This error occurred: '+str(e)+'\n Do not make the same mistake again. If you are intending to use a jinx, you must `invoke_jinx`. If you just need to answer, choose `answer`.',
1088
- model= model,
1089
- provider = provider,
1090
- api_url = api_url,
1091
- api_key = api_key,
1092
- npc = npc,
1093
- team = team,
1094
- messages = messages,
1095
- images = images,
1096
- stream=stream,
1097
- context=context,
1098
- actions=actions,
1099
-
1100
- **kwargs,
742
+ stream=stream,
743
+ context=context,
744
+ tools=tools,
1101
745
  )
746
+ except Exception as e:
747
+ # If continuation fails, return what we have so far
748
+ # The tool was already executed successfully
749
+ logger.warning(f"[check_llm_command] Continuation failed: {e}")
750
+ return {
751
+ "messages": current_messages,
752
+ "output": f"Tool executed successfully. (Continuation error: {type(e).__name__})",
753
+ "usage": total_usage,
754
+ }
1102
755
 
1103
- action_output = result.get('output') or result.get('response')
1104
-
1105
- if stream and len(planned_actions) > 1:
1106
-
1107
- action_output = print_and_process_stream_with_markdown(action_output, model, provider)
1108
- elif len(planned_actions) == 1:
1109
-
1110
-
1111
- return {"messages": result.get('messages',
1112
- current_messages),
1113
- "output": action_output}
1114
- step_outputs.append(action_output)
1115
- current_messages = result.get('messages',
1116
- current_messages)
756
+ if response.get("usage"):
757
+ total_usage["input_tokens"] += response["usage"].get("input_tokens", 0)
758
+ total_usage["output_tokens"] += response["usage"].get("output_tokens", 0)
759
+
760
+ tool_calls = response.get("tool_calls", [])
761
+ # Append assistant response to full messages with tool_calls if present
762
+ assistant_response = response.get("response", "")
763
+ assistant_msg = {"role": "assistant", "content": assistant_response}
764
+ if tool_calls:
765
+ assistant_msg["tool_calls"] = _serialize_tool_calls(tool_calls)
766
+ current_messages.append(assistant_msg)
767
+
768
+ if not tool_calls:
769
+ # Done - return full message history
770
+ logger.debug(f"[check_llm_command] Tool loop done - returning {len(current_messages)} messages")
771
+ return {
772
+ "messages": current_messages,
773
+ "output": assistant_response,
774
+ "usage": total_usage,
775
+ }
1117
776
 
1118
-
1119
-
1120
- final_output = compile_sequence_results(
1121
- original_command=command,
1122
- outputs=step_outputs,
1123
- model=model,
1124
- provider=provider,
1125
- npc=npc,
1126
- stream=stream,
1127
- context=context,
1128
- **kwargs
1129
- )
1130
-
1131
- return {"messages": current_messages,
1132
- "output": final_output}
1133
-
1134
- def compile_sequence_results(original_command: str,
1135
- outputs: List[str],
1136
- model: str = None,
1137
- provider: str = None,
1138
- npc: Any = None,
1139
- team: Any = None,
1140
- context: str = None,
1141
- stream: bool = False,
1142
- **kwargs) -> str:
1143
- """
1144
- Synthesizes a list of outputs from sequential steps into a single,
1145
- coherent final response, framed as an answer to the original query.
1146
- """
1147
- if not outputs:
1148
- return "The process completed, but produced no output."
1149
- synthesis_prompt = f"""
1150
- A user asked the following question:
1151
- "{original_command}"
777
+ logger.debug(f"[check_llm_command] Max iterations - returning {len(current_messages)} messages")
778
+ return {
779
+ "messages": current_messages,
780
+ "output": response.get("response", "Max iterations reached"),
781
+ "usage": total_usage,
782
+ }
1152
783
 
1153
- To answer this, the following information was gathered in sequential steps:
1154
- {json.dumps(outputs, indent=2)}
1155
784
 
1156
- Based *directly on the user's original question* and the information gathered, please
1157
- provide a single, final, and coherent response. Answer the user's question directly.
1158
- Do not mention the steps taken.
785
+ def _react_fallback(
786
+ command, model, provider, api_url, api_key, npc, team,
787
+ messages, images, stream, context, jinxs, extra_globals, max_iterations
788
+ ):
789
+ """ReAct-style fallback for models without tool calling."""
790
+ import logging
791
+ logger = logging.getLogger("npcpy.llm_funcs")
792
+ logger.debug(f"[_react_fallback] Starting with {len(messages) if messages else 0} messages")
1159
793
 
1160
- Final Synthesized Response that addresses the user in a polite and informative manner:
1161
- """
794
+ total_usage = {"input_tokens": 0, "output_tokens": 0}
795
+ current_messages = messages.copy() if messages else []
796
+ logger.debug(f"[_react_fallback] current_messages initialized with {len(current_messages)} messages")
1162
797
 
1163
- response = get_llm_response(
1164
- synthesis_prompt,
1165
- model=model,
1166
- provider=provider,
1167
- npc=npc,
1168
- team=team,
1169
- messages=[],
1170
- stream=stream,
1171
- context=context,
1172
- **kwargs
1173
- )
1174
- synthesized = response.get("response", "")
1175
- if synthesized:
1176
- return synthesized
1177
- return '\n'.join(outputs)
798
+ # Build jinx list with input parameters
799
+ def _jinx_info(name, jinx):
800
+ inputs = getattr(jinx, 'inputs', [])
801
+ input_names = [i if isinstance(i, str) else list(i.keys())[0] for i in inputs]
802
+ return f"- {name}({', '.join(input_names)}): {jinx.description}"
1178
803
 
804
+ jinx_list = "\n".join(_jinx_info(n, j) for n, j in jinxs.items()) if jinxs else "None"
1179
805
 
806
+ for iteration in range(max_iterations):
807
+ prompt = f"""Request: {command}
1180
808
 
1181
- def should_continue_with_more_actions(
1182
- original_command: str,
1183
- completed_actions: List[Dict[str, Any]],
1184
- current_messages: List[Dict[str, str]],
1185
- model: str = None,
1186
- provider: str = None,
1187
- npc: Any = None,
1188
- team: Any = None,
1189
- context: str = None,
1190
- **kwargs: Any
1191
-
1192
- ) -> Dict:
1193
- """Decide if more action sequences are needed."""
1194
-
1195
- results_summary = ""
1196
- for idx, action_result in enumerate(completed_actions):
1197
- action_name = action_result.get("action", "Unknown Action")
1198
- output = action_result.get('output', 'No Output')
1199
- output_preview = output[:100] + "..." if isinstance(output, str) and len(output) > 100 else output
1200
- results_summary += f"{idx + 1}. {action_name}: {output_preview}\n"
809
+ Tools:
810
+ {jinx_list}
1201
811
 
1202
- prompt = f"""
1203
- Original user request: "{original_command}"
812
+ Return JSON: {{"action": "answer", "response": "..."}} OR {{"action": "jinx", "jinx_name": "...", "inputs": {{"param_name": "value"}}}}
813
+ Use EXACT parameter names from the tool definitions above."""
1204
814
 
1205
- This request asks for multiple things. Analyze if ALL parts have been addressed.
1206
- Look for keywords like "and then", "use that to", "after that" which indicate multiple tasks.
815
+ if context:
816
+ prompt += f"\nContext: {context}"
1207
817
 
1208
- Completed actions so far:
1209
- {results_summary}
818
+ response = get_llm_response(
819
+ prompt,
820
+ model=model,
821
+ provider=provider,
822
+ api_url=api_url,
823
+ api_key=api_key,
824
+ messages=current_messages[-10:],
825
+ npc=npc,
826
+ team=team,
827
+ images=images if iteration == 0 else None,
828
+ format="json",
829
+ context=context,
830
+ )
1210
831
 
1211
- For the request "{original_command}", identify:
1212
- 1. What parts have been completed
1213
- 2. What parts still need to be done
832
+ if response.get("usage"):
833
+ total_usage["input_tokens"] += response["usage"].get("input_tokens", 0)
834
+ total_usage["output_tokens"] += response["usage"].get("output_tokens", 0)
1214
835
 
1215
- JSON response:
1216
- {{
1217
- "needs_more_actions": true/false,
1218
- "reasoning": "explain what's been done and what's still needed",
1219
- "next_focus": "if more actions needed, what specific task should be done next"
1220
- }}
1221
- """
836
+ decision = response.get("response", {})
837
+ if isinstance(decision, str):
838
+ try:
839
+ decision = json.loads(decision)
840
+ except:
841
+ return {"messages": current_messages, "output": decision, "usage": total_usage}
842
+
843
+ if decision.get("action") == "answer":
844
+ output = decision.get("response", "")
845
+ # Add user message to full history
846
+ current_messages.append({"role": "user", "content": command})
847
+ if stream:
848
+ # Use truncated for API, keep full history
849
+ final = get_llm_response(command, model=model, provider=provider, messages=current_messages[-10:], npc=npc, team=team, stream=True, context=context)
850
+ if final.get("usage"):
851
+ total_usage["input_tokens"] += final["usage"].get("input_tokens", 0)
852
+ total_usage["output_tokens"] += final["usage"].get("output_tokens", 0)
853
+ # Return full messages, not truncated from final
854
+ logger.debug(f"[_react_fallback] Answer (stream) - returning {len(current_messages)} messages")
855
+ return {"messages": current_messages, "output": final.get("response", output), "usage": total_usage}
856
+ # Non-streaming: add assistant response to full history
857
+ if output and isinstance(output, str):
858
+ current_messages.append({"role": "assistant", "content": output})
859
+ logger.debug(f"[_react_fallback] Answer - returning {len(current_messages)} messages")
860
+ return {"messages": current_messages, "output": output, "usage": total_usage}
861
+
862
+ elif decision.get("action") == "jinx":
863
+ jinx_name = decision.get("jinx_name")
864
+ inputs = decision.get("inputs", {})
865
+
866
+ if jinx_name not in jinxs:
867
+ context = f"Error: '{jinx_name}' not found. Available: {list(jinxs.keys())}"
868
+ continue
1222
869
 
1223
- response = get_llm_response(
1224
- prompt,
1225
- model=model,
1226
- provider=provider,
1227
- npc=npc,
1228
- team=team,
1229
- format="json",
1230
- messages=[],
1231
-
1232
- context=context,
1233
- **kwargs
1234
- )
1235
-
1236
- response_dict = response.get("response", {})
1237
- if not isinstance(response_dict, dict):
1238
- return {"needs_more_actions": False, "reasoning": "Error", "next_focus": ""}
1239
-
1240
- return response_dict
870
+ try:
871
+ from termcolor import colored
872
+ print(colored(f" ⚡ {jinx_name}", "cyan"), end="", flush=True)
873
+ except:
874
+ pass
875
+ output = _execute_jinx(jinxs[jinx_name], inputs, npc, team, current_messages, extra_globals)
876
+ try:
877
+ print(colored(" ✓", "green"), flush=True)
878
+ except:
879
+ pass
880
+ context = f"Tool '{jinx_name}' returned: {output}"
881
+ command = f"{command}\n\nPrevious: {context}"
1241
882
 
883
+ else:
884
+ logger.debug(f"[_react_fallback] Unknown action - returning {len(current_messages)} messages")
885
+ return {"messages": current_messages, "output": str(decision), "usage": total_usage}
1242
886
 
887
+ logger.debug(f"[_react_fallback] Max iterations - returning {len(current_messages)} messages")
888
+ return {"messages": current_messages, "output": f"Max iterations reached. Last: {context}", "usage": total_usage}
1243
889
 
1244
890
 
1245
891
 
@@ -2432,4 +2078,4 @@ def synthesize(
2432
2078
  team=team,
2433
2079
  context=context,
2434
2080
  **kwargs
2435
- )
2081
+ )