dasein-core 0.2.7__py3-none-any.whl → 0.2.10__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.
Files changed (46) hide show
  1. dasein/api.py +1144 -133
  2. dasein/capture.py +2325 -1803
  3. dasein/microturn.py +475 -0
  4. dasein/models/en_core_web_sm/en_core_web_sm/__init__.py +10 -0
  5. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/LICENSE +19 -0
  6. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/LICENSES_SOURCES +66 -0
  7. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/README.md +47 -0
  8. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/accuracy.json +330 -0
  9. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/attribute_ruler/patterns +0 -0
  10. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/config.cfg +269 -0
  11. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/lemmatizer/lookups/lookups.bin +1 -0
  12. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/meta.json +521 -0
  13. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/ner/cfg +13 -0
  14. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/ner/model +0 -0
  15. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/ner/moves +1 -0
  16. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/parser/cfg +13 -0
  17. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/parser/model +0 -0
  18. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/parser/moves +1 -0
  19. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/senter/cfg +3 -0
  20. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/senter/model +0 -0
  21. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/tagger/cfg +57 -0
  22. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/tagger/model +0 -0
  23. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/tok2vec/cfg +3 -0
  24. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/tok2vec/model +0 -0
  25. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/tokenizer +3 -0
  26. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/vocab/key2row +1 -0
  27. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/vocab/lookups.bin +0 -0
  28. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/vocab/strings.json +84782 -0
  29. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/vocab/vectors +0 -0
  30. dasein/models/en_core_web_sm/en_core_web_sm/en_core_web_sm-3.7.1/vocab/vectors.cfg +3 -0
  31. dasein/models/en_core_web_sm/en_core_web_sm/meta.json +521 -0
  32. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/LICENSE +19 -0
  33. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/LICENSES_SOURCES +66 -0
  34. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/METADATA +59 -0
  35. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/RECORD +35 -0
  36. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/WHEEL +5 -0
  37. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/entry_points.txt +2 -0
  38. dasein/models/en_core_web_sm/en_core_web_sm-3.7.1.dist-info/top_level.txt +1 -0
  39. dasein/pipecleaner.py +1917 -0
  40. dasein/wrappers.py +315 -0
  41. {dasein_core-0.2.7.dist-info → dasein_core-0.2.10.dist-info}/METADATA +4 -1
  42. dasein_core-0.2.10.dist-info/RECORD +59 -0
  43. dasein_core-0.2.7.dist-info/RECORD +0 -21
  44. {dasein_core-0.2.7.dist-info → dasein_core-0.2.10.dist-info}/WHEEL +0 -0
  45. {dasein_core-0.2.7.dist-info → dasein_core-0.2.10.dist-info}/licenses/LICENSE +0 -0
  46. {dasein_core-0.2.7.dist-info → dasein_core-0.2.10.dist-info}/top_level.txt +0 -0
dasein/capture.py CHANGED
@@ -1,1803 +1,2325 @@
1
- """
2
- Trace capture functionality for Dasein.
3
- """
4
-
5
- import hashlib
6
- from typing import Any, Dict, List, Optional, Union
7
- from datetime import datetime
8
- from langchain_core.callbacks.base import BaseCallbackHandler
9
- from langchain_core.callbacks.manager import CallbackManagerForToolRun
10
- from langchain_core.tools import BaseTool
11
-
12
-
13
- # ============================================================================
14
- # VERBOSE LOGGING HELPER
15
- # ============================================================================
16
-
17
- def _vprint(message: str, verbose: bool = False, force: bool = False):
18
- """
19
- Helper function for verbose printing.
20
-
21
- Args:
22
- message: Message to print
23
- verbose: Whether verbose mode is enabled
24
- force: If True, always print regardless of verbose setting
25
- """
26
- if force or verbose:
27
- print(message)
28
-
29
-
30
- # DEPRECATED: Global trace store removed for thread-safety
31
- # Traces are now stored instance-level in DaseinCallbackHandler._trace
32
- # _TRACE: List[Dict[str, Any]] = []
33
-
34
- # Hook cache for agent fingerprinting
35
- _HOOK_CACHE: Dict[str, Any] = {}
36
-
37
- # Store for modified tool inputs
38
- _MODIFIED_TOOL_INPUTS: Dict[str, str] = {}
39
-
40
-
41
- class DaseinToolWrapper(BaseTool):
42
- """Wrapper for tools that applies micro-turn modifications."""
43
-
44
- name: str = ""
45
- description: str = ""
46
- original_tool: Any = None
47
- callback_handler: Any = None
48
-
49
- def __init__(self, original_tool, callback_handler=None, verbose: bool = False):
50
- super().__init__(
51
- name=original_tool.name,
52
- description=original_tool.description
53
- )
54
- self.original_tool = original_tool
55
- self.callback_handler = callback_handler
56
- self._verbose = verbose
57
-
58
- def _vprint(self, message: str, force: bool = False):
59
- """Helper for verbose printing."""
60
- _vprint(message, self._verbose, force)
61
-
62
- def _run(self, *args, **kwargs):
63
- """Run the tool with micro-turn injection at execution level."""
64
- self._vprint(f"[DASEIN][TOOL_WRAPPER] _run called for {self.name} - VERSION 2.0")
65
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Args: {args}")
66
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Kwargs: {kwargs}")
67
-
68
- try:
69
- # Get the original input
70
- original_input = args[0] if args else ""
71
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Original input: {original_input[:100]}...")
72
-
73
- # Apply micro-turn injection if we have rules
74
- modified_input = self._apply_micro_turn_injection(str(original_input))
75
-
76
- if modified_input != original_input:
77
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
78
- # Use modified input
79
- result = self.original_tool._run(modified_input, *args[1:], **kwargs)
80
- else:
81
- self._vprint(f"[DASEIN][TOOL_WRAPPER] No micro-turn injection applied for {self.name}")
82
- # Use original input
83
- result = self.original_tool._run(*args, **kwargs)
84
-
85
- # Capture the tool output in the trace
86
- self._vprint(f"[DASEIN][TOOL_WRAPPER] About to capture tool output for {self.name}")
87
- self._capture_tool_output(self.name, args, kwargs, result)
88
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Finished capturing tool output for {self.name}")
89
-
90
- return result
91
-
92
- except Exception as e:
93
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Exception in _run: {e}")
94
- import traceback
95
- traceback.print_exc()
96
- # Still try to call the original tool
97
- result = self.original_tool._run(*args, **kwargs)
98
- return result
99
-
100
- def invoke(self, input_data, config=None, **kwargs):
101
- """Invoke the tool with micro-turn injection."""
102
- # Get the original input
103
- original_input = str(input_data)
104
-
105
- # Apply micro-turn injection if we have rules
106
- modified_input = self._apply_micro_turn_injection(original_input)
107
-
108
- if modified_input != original_input:
109
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
110
- # Use modified input
111
- return self.original_tool.invoke(modified_input, config, **kwargs)
112
- else:
113
- # Use original input
114
- return self.original_tool.invoke(input_data, config, **kwargs)
115
-
116
- async def _arun(self, *args, **kwargs):
117
- """Async run the tool with micro-turn injection at execution level."""
118
- self._vprint(f"[DASEIN][TOOL_WRAPPER] _arun called for {self.name} - ASYNC VERSION")
119
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Args: {args}")
120
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Kwargs: {kwargs}")
121
-
122
- try:
123
- # Get the original input
124
- original_input = args[0] if args else ""
125
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Original input: {original_input[:100]}...")
126
-
127
- # Apply micro-turn injection if we have rules
128
- modified_input = self._apply_micro_turn_injection(str(original_input))
129
-
130
- if modified_input != original_input:
131
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
132
- # Use modified input
133
- result = await self.original_tool._arun(modified_input, *args[1:], **kwargs)
134
- else:
135
- self._vprint(f"[DASEIN][TOOL_WRAPPER] No micro-turn injection applied for {self.name}")
136
- # Use original input
137
- result = await self.original_tool._arun(*args, **kwargs)
138
-
139
- # Capture the tool output in the trace
140
- self._vprint(f"[DASEIN][TOOL_WRAPPER] About to capture tool output for {self.name}")
141
- self._capture_tool_output(self.name, args, kwargs, result)
142
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Finished capturing tool output for {self.name}")
143
-
144
- return result
145
-
146
- except Exception as e:
147
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Exception in _arun: {e}")
148
- import traceback
149
- traceback.print_exc()
150
- # Still try to call the original tool
151
- result = await self.original_tool._arun(*args, **kwargs)
152
- return result
153
-
154
- async def ainvoke(self, input_data, config=None, **kwargs):
155
- """Async invoke the tool with micro-turn injection."""
156
- self._vprint(f"[DASEIN][TOOL_WRAPPER] ainvoke called for {self.name} - ASYNC VERSION")
157
-
158
- # Get the original input
159
- original_input = str(input_data)
160
-
161
- # Apply micro-turn injection if we have rules
162
- modified_input = self._apply_micro_turn_injection(original_input)
163
-
164
- if modified_input != original_input:
165
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
166
- # Use modified input
167
- return await self.original_tool.ainvoke(modified_input, config, **kwargs)
168
- else:
169
- # Use original input
170
- return await self.original_tool.ainvoke(input_data, config, **kwargs)
171
-
172
- def _apply_micro_turn_injection(self, original_input: str) -> str:
173
- """Apply micro-turn injection to the tool input."""
174
- try:
175
- # Check if we have a callback handler with rules and LLM
176
- if not self.callback_handler:
177
- return original_input
178
-
179
- # Normalize selected rules into Rule objects (handle (rule, metadata) tuples)
180
- normalized_rules = []
181
- for rule_meta in getattr(self.callback_handler, "_selected_rules", []) or []:
182
- if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
183
- rule_obj, _metadata = rule_meta
184
- else:
185
- rule_obj = rule_meta
186
- normalized_rules.append(rule_obj)
187
-
188
- # Filter tool_start rules
189
- tool_rules = [r for r in normalized_rules if getattr(r, 'target_step_type', '') == "tool_start"]
190
-
191
- if not tool_rules:
192
- self._vprint(f"[DASEIN][MICROTURN] No tool rules selected - skipping micro-turn for {self.name}")
193
- return original_input
194
-
195
- # Check if any rule covers this tool
196
- covered_rules = [rule for rule in tool_rules
197
- if self._rule_covers_tool(rule, self.name, original_input)]
198
-
199
- if not covered_rules:
200
- return original_input
201
-
202
- # Fire micro-turn LLM call (use first matching rule)
203
- rule = covered_rules[0]
204
- self._vprint(f"[DASEIN][MICROTURN] rule_id={rule.id} tool={self.name}")
205
-
206
- # Create micro-turn prompt
207
- micro_turn_prompt = self._create_micro_turn_prompt(rule, self.name, original_input)
208
-
209
- # Execute micro-turn LLM call
210
- modified_input = self._execute_micro_turn_llm_call(micro_turn_prompt, original_input)
211
-
212
- self._vprint(f"[DASEIN][MICROTURN] Applied rule {rule.id}: {str(original_input)[:50]}... -> {str(modified_input)[:50]}...")
213
- return modified_input
214
-
215
- except Exception as e:
216
- self._vprint(f"[DASEIN][MICROTURN] Error in micro-turn injection: {e}")
217
- return original_input
218
-
219
- def _rule_covers_tool(self, rule, tool_name: str, tool_input: str) -> bool:
220
- """Check if a rule covers this tool call."""
221
- if not hasattr(rule, 'references') or not rule.references:
222
- return False
223
-
224
- # Check if the rule references this tool
225
- tools = rule.references.get('tools', [])
226
- return tool_name in tools
227
-
228
- def _create_micro_turn_prompt(self, rule, tool_name: str, tool_input: str) -> str:
229
- """Create the prompt for the micro-turn LLM call."""
230
- return f"""You are applying a rule to fix a tool input.
231
-
232
- Rule: {rule.advice_text}
233
-
234
- Tool: {tool_name}
235
- Current Input: {tool_input}
236
-
237
- Apply the rule to fix the input. Return only the corrected input, nothing else."""
238
-
239
- def _execute_micro_turn_llm_call(self, prompt: str, original_input: str) -> str:
240
- """Execute the actual micro-turn LLM call."""
241
- try:
242
- if not self.callback_handler or not self.callback_handler._llm:
243
- self._vprint(f"[DASEIN][MICROTURN] No LLM available for micro-turn call")
244
- return original_input
245
-
246
- self._vprint(f"[DASEIN][MICROTURN] Executing micro-turn LLM call")
247
- self._vprint(f"[DASEIN][MICROTURN] Prompt: {prompt[:200]}...")
248
-
249
- # Make the micro-turn LLM call
250
- messages = [{"role": "user", "content": prompt}]
251
- response = self.callback_handler._llm.invoke(messages)
252
-
253
- # Extract the response content
254
- if hasattr(response, 'content'):
255
- modified_input = response.content.strip()
256
- elif isinstance(response, str):
257
- modified_input = response.strip()
258
- else:
259
- modified_input = str(response).strip()
260
-
261
- self._vprint(f"[DASEIN][MICROTURN] LLM response: {modified_input[:100]}...")
262
-
263
- # 🚨 CRITICAL: Parse JSON responses with markdown fences
264
- if modified_input.startswith('```json') or modified_input.startswith('```'):
265
- try:
266
- # Extract JSON from markdown fences
267
- import re
268
- import json
269
- json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', modified_input, re.DOTALL)
270
- if json_match:
271
- json_str = json_match.group(1)
272
- parsed_json = json.loads(json_str)
273
- # Convert back to the expected format
274
- if isinstance(parsed_json, dict) and 'name' in parsed_json and 'args' in parsed_json:
275
- modified_input = parsed_json
276
- self._vprint(f"[DASEIN][MICROTURN] Parsed JSON from markdown fences: {parsed_json}")
277
- else:
278
- self._vprint(f"[DASEIN][MICROTURN] JSON doesn't have expected structure, using as-is")
279
- else:
280
- self._vprint(f"[DASEIN][MICROTURN] Could not extract JSON from markdown fences")
281
- except Exception as e:
282
- self._vprint(f"[DASEIN][MICROTURN] Error parsing JSON: {e}")
283
-
284
- # Validate the response - only fallback if completely empty
285
- if not modified_input:
286
- self._vprint(f"[DASEIN][MICROTURN] LLM response empty, using original input")
287
- return original_input
288
-
289
- return modified_input
290
-
291
- except Exception as e:
292
- self._vprint(f"[DASEIN][MICROTURN] Error executing micro-turn LLM call: {e}")
293
- return original_input
294
-
295
- def _capture_tool_output(self, tool_name, args, kwargs, result):
296
- """Capture tool output in the trace."""
297
- try:
298
- # Create args excerpt
299
- args_str = str(args) if args else ""
300
- if len(args_str) > 1000:
301
- args_str = args_str[:1000] + "..."
302
-
303
- # Create result excerpt (with 10k limit)
304
- result_str = str(result) if result else ""
305
- if len(result_str) > 10000:
306
- result_str = result_str[:10000] + "..."
307
-
308
- # Add tool_end step to trace
309
- step = {
310
- "step_type": "tool_end",
311
- "tool_name": tool_name,
312
- "args_excerpt": args_str,
313
- "outcome": result_str,
314
- "ts": datetime.now().isoformat(),
315
- "run_id": f"tool_{id(self)}_{datetime.now().timestamp()}",
316
- "parent_run_id": None,
317
- }
318
-
319
- # Add to LLM wrapper's trace if available
320
- if self.callback_handler and hasattr(self.callback_handler, '_llm') and self.callback_handler._llm:
321
- if hasattr(self.callback_handler._llm, '_trace'):
322
- self.callback_handler._llm._trace.append(step)
323
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Added to LLM wrapper trace")
324
- else:
325
- self._vprint(f"[DASEIN][TOOL_WRAPPER] LLM wrapper has no _trace attribute")
326
- else:
327
- self._vprint(f"[DASEIN][TOOL_WRAPPER] No LLM wrapper available")
328
-
329
- # Also add to callback handler's trace if it has one
330
- if self.callback_handler and hasattr(self.callback_handler, '_trace'):
331
- self.callback_handler._trace.append(step)
332
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Added to callback handler trace")
333
-
334
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Captured tool output for {tool_name}")
335
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Output length: {len(result_str)} chars")
336
- self._vprint(f"[DASEIN][TOOL_WRAPPER] First 200 chars: {result_str[:200]}")
337
- if self.callback_handler and hasattr(self.callback_handler, '_trace'):
338
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Callback handler trace length after capture: {len(self.callback_handler._trace)}")
339
-
340
- except Exception as e:
341
- self._vprint(f"[DASEIN][TOOL_WRAPPER] Error capturing tool output: {e}")
342
-
343
-
344
- class DaseinCallbackHandler(BaseCallbackHandler):
345
- """
346
- Callback handler that captures step-by-step traces and implements rule injection.
347
- """
348
-
349
- def __init__(self, weights=None, llm=None, is_langgraph=False, coordinator_node=None, planning_nodes=None, verbose: bool = False):
350
- super().__init__()
351
- self._weights = weights
352
- self._selected_rules = [] # Rules selected for this run
353
- self._injection_guard = set() # Prevent duplicate injections
354
- self._last_modified_prompts = [] # Store modified prompts for LLM wrapper
355
- self._llm = llm # Store reference to LLM for micro-turn calls
356
- self._tool_name_by_run_id = {} # Track tool names by run_id
357
- self._discovered_tools = set() # Track tools discovered during execution
358
- self._wrapped_dynamic_tools = {} # Cache of wrapped dynamic tools
359
- self._is_langgraph = is_langgraph # Flag to skip planning rule injection for LangGraph
360
- self._coordinator_node = coordinator_node # Coordinator node (for future targeted injection)
361
- self._planning_nodes = planning_nodes if planning_nodes else set() # Planning-capable nodes (including subgraph children)
362
- self._current_chain_node = None # Track current LangGraph node
363
- self._agent_was_recreated = False # Track if agent was successfully recreated
364
- self._function_calls_made = {} # Track function calls: {function_name: [{'step': N, 'ts': timestamp}]}
365
- self._trace = [] # Instance-level trace storage (not global) for thread-safety
366
- self._verbose = verbose
367
- self._start_times = {} # Track start times for duration calculation: {step_index: datetime}
368
- self._vprint(f"[DASEIN][CALLBACK] Initialized callback handler (LangGraph: {is_langgraph})")
369
- if coordinator_node:
370
- self._vprint(f"[DASEIN][CALLBACK] Coordinator: {coordinator_node}")
371
- if planning_nodes:
372
- self._vprint(f"[DASEIN][CALLBACK] Planning nodes: {planning_nodes}")
373
- self._vprint(f"[DASEIN][CALLBACK] Dynamic tool detection enabled (tools discovered at runtime)")
374
-
375
- def _vprint(self, message: str, force: bool = False):
376
- """Helper for verbose printing."""
377
- _vprint(message, self._verbose, force)
378
-
379
- def reset_run_state(self):
380
- """Reset state that should be cleared between runs."""
381
- self._function_calls_made = {}
382
- self._injection_guard = set()
383
- self._trace = [] # Clear instance trace
384
- self._start_times = {} # Clear start times
385
- self._vprint(f"[DASEIN][CALLBACK] Reset run state (trace, function calls, injection guard, and start times cleared)")
386
-
387
- def on_llm_start(
388
- self,
389
- serialized: Dict[str, Any],
390
- prompts: List[str],
391
- **kwargs: Any,
392
- ) -> None:
393
- """Called when an LLM starts running."""
394
- model_name = serialized.get("name", "unknown") if serialized else "unknown"
395
-
396
- # 🎯 CRITICAL: Track current node from kwargs metadata (LangGraph includes langgraph_node)
397
- if self._is_langgraph and 'metadata' in kwargs and isinstance(kwargs['metadata'], dict):
398
- if 'langgraph_node' in kwargs['metadata']:
399
- node_name = kwargs['metadata']['langgraph_node']
400
- self._current_chain_node = node_name
401
-
402
- # Inject rules if applicable
403
- modified_prompts = self._inject_rule_if_applicable("llm_start", model_name, prompts)
404
-
405
- # Store the modified prompts for the LLM wrapper to use
406
- self._last_modified_prompts = modified_prompts
407
-
408
- # 🚨 OPTIMIZED: For LangGraph, check if kwargs contains 'invocation_params' with messages
409
- # Extract the most recent message instead of full history
410
- # Use from_end=True to capture the END of system prompts (where user's actual query is)
411
- if 'invocation_params' in kwargs and 'messages' in kwargs['invocation_params']:
412
- args_excerpt = self._extract_recent_message({'messages': kwargs['invocation_params']['messages']})
413
- else:
414
- args_excerpt = self._excerpt(" | ".join(modified_prompts), from_end=True)
415
-
416
- # GNN-related fields
417
- step_index = len(self._trace)
418
-
419
- # Track which rules triggered at this step (llm_start rules)
420
- rule_triggered_here = []
421
- if hasattr(self, '_selected_rules') and self._selected_rules:
422
- for rule_meta in self._selected_rules:
423
- if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
424
- rule_obj, _metadata = rule_meta
425
- else:
426
- rule_obj = rule_meta
427
- target_step_type = getattr(rule_obj, 'target_step_type', '')
428
- if target_step_type in ['llm_start', 'chain_start']:
429
- rule_triggered_here.append(getattr(rule_obj, 'id', 'unknown'))
430
-
431
- # Record start time for duration calculation
432
- start_time = datetime.now()
433
- self._start_times[step_index] = start_time
434
-
435
- step = {
436
- "step_type": "llm_start",
437
- "tool_name": model_name,
438
- "args_excerpt": args_excerpt,
439
- "outcome": "",
440
- "ts": start_time.isoformat(),
441
- "run_id": None,
442
- "parent_run_id": None,
443
- "node": self._current_chain_node, # LangGraph node name (if available)
444
- # GNN step-level fields
445
- "step_index": step_index,
446
- "rule_triggered_here": rule_triggered_here,
447
- }
448
- self._trace.append(step)
449
- # self._vprint(f"[DASEIN][CALLBACK] Captured llm_start: {len(_TRACE)} total steps") # Commented out - too noisy
450
-
451
- def on_llm_end(
452
- self,
453
- response: Any,
454
- **kwargs: Any,
455
- ) -> None:
456
- """Called when an LLM ends running."""
457
- outcome = ""
458
- try:
459
- # Debug: Print ALL available data to see what we're getting
460
- # print(f"[DEBUG] on_llm_end called")
461
- # print(f" response type: {type(response)}")
462
- # print(f" kwargs keys: {kwargs.keys()}")
463
-
464
- # Try multiple extraction strategies
465
- # Strategy 1: Standard LangChain LLMResult structure
466
- if hasattr(response, 'generations') and response.generations:
467
- if len(response.generations) > 0:
468
- first_gen = response.generations[0]
469
- if isinstance(first_gen, list) and len(first_gen) > 0:
470
- generation = first_gen[0]
471
- else:
472
- generation = first_gen
473
-
474
- # Try multiple content fields
475
- if hasattr(generation, 'text') and generation.text:
476
- outcome = self._excerpt(generation.text)
477
- elif hasattr(generation, 'message'):
478
- if hasattr(generation.message, 'content'):
479
- outcome = self._excerpt(generation.message.content)
480
- elif hasattr(generation.message, 'text'):
481
- outcome = self._excerpt(generation.message.text)
482
- elif hasattr(generation, 'content'):
483
- outcome = self._excerpt(generation.content)
484
- else:
485
- outcome = self._excerpt(str(generation))
486
-
487
- # Strategy 2: Check if response itself has content
488
- elif hasattr(response, 'content'):
489
- outcome = self._excerpt(response.content)
490
-
491
- # Strategy 3: Check kwargs for output/response
492
- elif 'output' in kwargs:
493
- outcome = self._excerpt(str(kwargs['output']))
494
- elif 'result' in kwargs:
495
- outcome = self._excerpt(str(kwargs['result']))
496
-
497
- # Fallback
498
- if not outcome:
499
- outcome = self._excerpt(str(response))
500
-
501
- # Debug: Warn if still empty
502
- if not outcome or len(outcome) == 0:
503
- self._vprint(f"[DASEIN][CALLBACK] WARNING: on_llm_end got empty outcome!")
504
- print(f" Response: {str(response)[:200]}")
505
- print(f" kwargs keys: {list(kwargs.keys())}")
506
-
507
- except (AttributeError, IndexError, TypeError) as e:
508
- self._vprint(f"[DASEIN][CALLBACK] Error in on_llm_end: {e}")
509
- outcome = self._excerpt(str(response))
510
-
511
- # 🎯 CRITICAL: Extract function calls for state tracking (agent-agnostic)
512
- try:
513
- if hasattr(response, 'generations') and response.generations:
514
- first_gen = response.generations[0]
515
- if isinstance(first_gen, list) and len(first_gen) > 0:
516
- generation = first_gen[0]
517
- else:
518
- generation = first_gen
519
-
520
- # Check for function_call in message additional_kwargs
521
- if hasattr(generation, 'message') and hasattr(generation.message, 'additional_kwargs'):
522
- func_call = generation.message.additional_kwargs.get('function_call')
523
- if func_call and isinstance(func_call, dict) and 'name' in func_call:
524
- func_name = func_call['name']
525
- step_num = len(self._trace)
526
-
527
- # Extract arguments and create preview
528
- args_str = func_call.get('arguments', '')
529
- preview = ''
530
- if args_str and len(args_str) > 0:
531
- # Take first 100 chars as preview
532
- preview = args_str[:100].replace('\n', ' ').replace('\r', '')
533
- if len(args_str) > 100:
534
- preview += '...'
535
-
536
- call_info = {
537
- 'step': step_num,
538
- 'ts': datetime.now().isoformat(),
539
- 'preview': preview
540
- }
541
-
542
- if func_name not in self._function_calls_made:
543
- self._function_calls_made[func_name] = []
544
- self._function_calls_made[func_name].append(call_info)
545
-
546
- self._vprint(f"[DASEIN][STATE] Tracked function call: {func_name} (count: {len(self._function_calls_made[func_name])})")
547
- except Exception as e:
548
- pass # Silently skip if function call extraction fails
549
-
550
- # Extract token usage from response metadata
551
- input_tokens = 0
552
- output_tokens = 0
553
- try:
554
- # DEBUG: Print response structure for first LLM call
555
- # Uncomment to see token structure:
556
- # import json
557
- # print(f"[DEBUG] Response structure:")
558
- # print(f" Has llm_output: {hasattr(response, 'llm_output')}")
559
- # if hasattr(response, 'llm_output'):
560
- # print(f" llm_output keys: {response.llm_output.keys() if response.llm_output else None}")
561
- # print(f" Has generations: {hasattr(response, 'generations')}")
562
- # if hasattr(response, 'generations') and response.generations:
563
- # gen = response.generations[0][0] if isinstance(response.generations[0], list) else response.generations[0]
564
- # print(f" generation_info: {gen.generation_info if hasattr(gen, 'generation_info') else None}")
565
-
566
- # Try LangChain's standard llm_output field
567
- if hasattr(response, 'llm_output') and response.llm_output:
568
- llm_output = response.llm_output
569
- # Different providers use different field names
570
- if 'token_usage' in llm_output:
571
- usage = llm_output['token_usage']
572
- input_tokens = usage.get('prompt_tokens', 0) or usage.get('input_tokens', 0)
573
- output_tokens = usage.get('completion_tokens', 0) or usage.get('output_tokens', 0)
574
- elif 'usage_metadata' in llm_output:
575
- usage = llm_output['usage_metadata']
576
- input_tokens = usage.get('input_tokens', 0) or usage.get('prompt_tokens', 0)
577
- output_tokens = usage.get('output_tokens', 0) or usage.get('completion_tokens', 0)
578
-
579
- # Try generations metadata (Google GenAI format)
580
- if (input_tokens == 0 and output_tokens == 0) and hasattr(response, 'generations') and response.generations:
581
- first_gen = response.generations[0]
582
- if isinstance(first_gen, list) and len(first_gen) > 0:
583
- gen = first_gen[0]
584
- else:
585
- gen = first_gen
586
-
587
- # Check message.usage_metadata (Google GenAI stores it here!)
588
- if hasattr(gen, 'message') and hasattr(gen.message, 'usage_metadata'):
589
- usage = gen.message.usage_metadata
590
- input_tokens = usage.get('input_tokens', 0)
591
- output_tokens = usage.get('output_tokens', 0)
592
-
593
- # Fallback: Check generation_info
594
- elif hasattr(gen, 'generation_info') and gen.generation_info:
595
- gen_info = gen.generation_info
596
- if 'usage_metadata' in gen_info:
597
- usage = gen_info['usage_metadata']
598
- input_tokens = usage.get('prompt_token_count', 0) or usage.get('input_tokens', 0)
599
- output_tokens = usage.get('candidates_token_count', 0) or usage.get('output_tokens', 0)
600
-
601
- # Log if we got tokens
602
- # if input_tokens > 0 or output_tokens > 0:
603
- # self._vprint(f"[DASEIN][TOKENS] Captured: {input_tokens} in, {output_tokens} out")
604
-
605
- except Exception as e:
606
- # Print error for debugging
607
- self._vprint(f"[DASEIN][CALLBACK] Error extracting tokens: {e}")
608
- import traceback
609
- traceback.print_exc()
610
-
611
- # GNN-related fields: compute tokens_delta
612
- step_index = len(self._trace)
613
- tokens_delta = 0
614
- # Find previous step with tokens_output to compute delta
615
- for prev_step in reversed(self._trace):
616
- if 'tokens_output' in prev_step and prev_step['tokens_output'] > 0:
617
- tokens_delta = output_tokens - prev_step['tokens_output']
618
- break
619
-
620
- # Calculate duration_ms by matching with corresponding llm_start
621
- duration_ms = 0
622
- for i in range(len(self._trace) - 1, -1, -1):
623
- if self._trace[i].get('step_type') == 'llm_start':
624
- # Found the matching llm_start
625
- if i in self._start_times:
626
- start_time = self._start_times[i]
627
- end_time = datetime.now()
628
- duration_ms = int((end_time - start_time).total_seconds() * 1000)
629
- # Update the llm_start step with duration_ms
630
- self._trace[i]['duration_ms'] = duration_ms
631
- break
632
-
633
- step = {
634
- "step_type": "llm_end",
635
- "tool_name": "",
636
- "args_excerpt": "",
637
- "outcome": self._excerpt(outcome, max_len=1000), # Truncate to 1000 chars
638
- "ts": datetime.now().isoformat(),
639
- "run_id": None,
640
- "parent_run_id": None,
641
- "tokens_input": input_tokens,
642
- "tokens_output": output_tokens,
643
- "node": self._current_chain_node, # LangGraph node name (if available)
644
- # GNN step-level fields
645
- "step_index": step_index,
646
- "tokens_delta": tokens_delta,
647
- "duration_ms": duration_ms,
648
- }
649
- self._trace.append(step)
650
-
651
- def on_agent_action(
652
- self,
653
- action: Any,
654
- **kwargs: Any,
655
- ) -> None:
656
- """Called when an agent takes an action."""
657
- tool_name = getattr(action, 'tool', 'unknown')
658
- args_excerpt = self._excerpt(str(getattr(action, 'tool_input', '')))
659
- outcome = self._excerpt(str(getattr(action, 'log', '')))
660
-
661
- step = {
662
- "step_type": "agent_action",
663
- "tool_name": tool_name,
664
- "args_excerpt": args_excerpt,
665
- "outcome": outcome,
666
- "ts": datetime.now().isoformat(),
667
- "run_id": None,
668
- "parent_run_id": None,
669
- }
670
- self._trace.append(step)
671
-
672
- def on_agent_finish(
673
- self,
674
- finish: Any,
675
- **kwargs: Any,
676
- ) -> None:
677
- """Called when an agent finishes."""
678
- outcome = self._excerpt(str(getattr(finish, 'return_values', '')))
679
-
680
- step = {
681
- "step_type": "agent_finish",
682
- "tool_name": None,
683
- "args_excerpt": "",
684
- "outcome": outcome,
685
- "ts": datetime.now().isoformat(),
686
- "run_id": None,
687
- "parent_run_id": None,
688
- }
689
- self._trace.append(step)
690
-
691
- def on_tool_start(
692
- self,
693
- serialized: Dict[str, Any],
694
- input_str: str,
695
- *,
696
- run_id: str,
697
- parent_run_id: Optional[str] = None,
698
- tags: Optional[List[str]] = None,
699
- metadata: Optional[Dict[str, Any]] = None,
700
- inputs: Optional[Dict[str, Any]] = None,
701
- **kwargs: Any,
702
- ) -> None:
703
- """Called when a tool starts running.
704
-
705
- This is where we detect and track dynamic tools that weren't
706
- statically attached to the agent at init time.
707
- """
708
- tool_name = serialized.get("name", "unknown") if serialized else "unknown"
709
-
710
- # Track discovered tools for reporting
711
- if tool_name != "unknown" and tool_name not in self._discovered_tools:
712
- self._discovered_tools.add(tool_name)
713
- # Tool discovered and tracked (silently)
714
-
715
- # Store tool name for later use in on_tool_end
716
- self._tool_name_by_run_id[run_id] = tool_name
717
-
718
- # Apply tool-level rule injection
719
- # self._vprint(f"[DASEIN][CALLBACK] on_tool_start called!") # Commented out - too noisy
720
- # self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}") # Commented out - too noisy
721
- # self._vprint(f"[DASEIN][CALLBACK] Input: {input_str[:100]}...") # Commented out - too noisy
722
- # self._vprint(f"[DASEIN][APPLY] on_tool_start: selected_rules={len(self._selected_rules)}") # Commented out - too noisy
723
- modified_input = self._inject_tool_rule_if_applicable("tool_start", tool_name, input_str)
724
-
725
- args_excerpt = self._excerpt(modified_input)
726
-
727
- # GNN-related fields: capture step-level metrics
728
- step_index = len(self._trace)
729
- tool_input_chars = len(str(input_str))
730
-
731
- # Track which rules triggered at this step
732
- rule_triggered_here = []
733
- if hasattr(self, '_selected_rules') and self._selected_rules:
734
- for rule_meta in self._selected_rules:
735
- if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
736
- rule_obj, _metadata = rule_meta
737
- else:
738
- rule_obj = rule_meta
739
- if getattr(rule_obj, 'target_step_type', '') == "tool_start":
740
- rule_triggered_here.append(getattr(rule_obj, 'id', 'unknown'))
741
-
742
- # Record start time for duration calculation (keyed by run_id for tools)
743
- start_time = datetime.now()
744
- self._start_times[run_id] = start_time
745
-
746
- step = {
747
- "step_type": "tool_start",
748
- "tool_name": tool_name,
749
- "args_excerpt": args_excerpt,
750
- "outcome": "",
751
- "ts": start_time.isoformat(),
752
- "run_id": run_id,
753
- "parent_run_id": parent_run_id,
754
- "node": self._current_chain_node, # LangGraph node name (if available)
755
- # GNN step-level fields
756
- "step_index": step_index,
757
- "tool_input_chars": tool_input_chars,
758
- "rule_triggered_here": rule_triggered_here,
759
- }
760
- self._trace.append(step)
761
-
762
- def on_tool_end(
763
- self,
764
- output: str,
765
- *,
766
- run_id: str,
767
- parent_run_id: Optional[str] = None,
768
- tags: Optional[List[str]] = None,
769
- **kwargs: Any,
770
- ) -> None:
771
- """Called when a tool ends running."""
772
- # Get the tool name from the corresponding tool_start
773
- tool_name = self._tool_name_by_run_id.get(run_id, "unknown")
774
-
775
- # Handle different output types (LangGraph may pass ToolMessage objects)
776
- output_str = str(output)
777
- outcome = self._excerpt(output_str)
778
-
779
- # self._vprint(f"[DASEIN][CALLBACK] on_tool_end called!") # Commented out - too noisy
780
- # self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}") # Commented out - too noisy
781
- # self._vprint(f"[DASEIN][CALLBACK] Output length: {len(output_str)} chars") # Commented out - too noisy
782
- # self._vprint(f"[DASEIN][CALLBACK] Outcome length: {len(outcome)} chars") # Commented out - too noisy
783
-
784
- # GNN-related fields: capture tool output metrics
785
- step_index = len(self._trace)
786
- tool_output_chars = len(output_str)
787
-
788
- # Estimate tool_output_items (heuristic: count lines, or rows if SQL-like)
789
- tool_output_items = 0
790
- try:
791
- # Try to count lines as a proxy for items
792
- if output_str:
793
- tool_output_items = output_str.count('\n') + 1
794
- except:
795
- tool_output_items = 0
796
-
797
- # Calculate duration_ms using run_id to match with tool_start
798
- duration_ms = 0
799
- if run_id in self._start_times:
800
- start_time = self._start_times[run_id]
801
- end_time = datetime.now()
802
- duration_ms = int((end_time - start_time).total_seconds() * 1000)
803
- # Update the corresponding tool_start step with duration_ms
804
- for i in range(len(self._trace) - 1, -1, -1):
805
- if self._trace[i].get('step_type') == 'tool_start' and self._trace[i].get('run_id') == run_id:
806
- self._trace[i]['duration_ms'] = duration_ms
807
- break
808
- # Clean up start time
809
- del self._start_times[run_id]
810
-
811
- # Extract available selectors from DOM-like output (web browse agents)
812
- available_selectors = None
813
- if tool_name in ['extract_text', 'get_elements', 'extract_hyperlinks', 'extract_content']:
814
- available_selectors = self._extract_semantic_selectors(output_str)
815
-
816
- step = {
817
- "step_type": "tool_end",
818
- "tool_name": tool_name,
819
- "args_excerpt": "",
820
- "outcome": self._excerpt(outcome, max_len=1000), # Truncate to 1000 chars
821
- "ts": datetime.now().isoformat(),
822
- "run_id": run_id,
823
- "parent_run_id": parent_run_id,
824
- "node": self._current_chain_node, # LangGraph node name (if available)
825
- # GNN step-level fields
826
- "step_index": step_index,
827
- "tool_output_chars": tool_output_chars,
828
- "tool_output_items": tool_output_items,
829
- "duration_ms": duration_ms,
830
- }
831
-
832
- # Add available_selectors only if found (keep trace light)
833
- if available_selectors:
834
- step["available_selectors"] = available_selectors
835
- self._trace.append(step)
836
-
837
- # Clean up the stored tool name
838
- if run_id in self._tool_name_by_run_id:
839
- del self._tool_name_by_run_id[run_id]
840
-
841
- def on_tool_error(
842
- self,
843
- error: BaseException,
844
- *,
845
- run_id: str,
846
- parent_run_id: Optional[str] = None,
847
- tags: Optional[List[str]] = None,
848
- **kwargs: Any,
849
- ) -> None:
850
- """Called when a tool encounters an error."""
851
- error_msg = self._excerpt(str(error))
852
-
853
- step = {
854
- "step_type": "tool_error",
855
- "tool_name": "",
856
- "args_excerpt": "",
857
- "outcome": f"ERROR: {error_msg}",
858
- "ts": datetime.now().isoformat(),
859
- "run_id": run_id,
860
- "parent_run_id": parent_run_id,
861
- }
862
- self._trace.append(step)
863
-
864
- def on_chain_start(
865
- self,
866
- serialized: Dict[str, Any],
867
- inputs: Dict[str, Any],
868
- **kwargs: Any,
869
- ) -> None:
870
- """Called when a chain starts running."""
871
- chain_name = serialized.get("name", "unknown") if serialized else "unknown"
872
- # self._vprint(f"[DASEIN][CALLBACK] on_chain_start called!") # Commented out - too noisy
873
- # self._vprint(f"[DASEIN][CALLBACK] Chain: {chain_name}") # Commented out - too noisy
874
-
875
- # 🚨 OPTIMIZED: For LangGraph agents, suppress redundant chain_start events
876
- # LangGraph fires on_chain_start for every internal node, creating noise
877
- # We already capture llm_start, llm_end, tool_start, tool_end which are more meaningful
878
- if self._is_langgraph:
879
- # Track current chain node for future targeted injection
880
- # 🎯 CRITICAL: Extract actual node name from metadata (same as on_llm_start)
881
- if 'metadata' in kwargs and isinstance(kwargs['metadata'], dict):
882
- if 'langgraph_node' in kwargs['metadata']:
883
- self._current_chain_node = kwargs['metadata']['langgraph_node']
884
- else:
885
- self._current_chain_node = chain_name
886
- else:
887
- self._current_chain_node = chain_name
888
-
889
- # self._vprint(f"[DASEIN][CALLBACK] Suppressing redundant chain_start for LangGraph agent") # Commented out - too noisy
890
- # Still handle tool executors
891
- if chain_name in {"tools", "ToolNode", "ToolExecutor"}:
892
- # self._vprint(f"[DASEIN][CALLBACK] Bridging chain_start to tool_start for {chain_name}") # Commented out - too noisy
893
- pass
894
- self._handle_tool_executor_start(serialized, inputs, **kwargs)
895
- return
896
-
897
- # For standard LangChain agents, keep chain_start events
898
- # Bridge to tool_start for tool executors
899
- if chain_name in {"tools", "ToolNode", "ToolExecutor"}:
900
- # self._vprint(f"[DASEIN][CALLBACK] Bridging chain_start to tool_start for {chain_name}") # Commented out - too noisy
901
- self._handle_tool_executor_start(serialized, inputs, **kwargs)
902
-
903
- args_excerpt = self._excerpt(str(inputs))
904
-
905
- # Record start time for duration calculation
906
- step_index = len(self._trace)
907
- start_time = datetime.now()
908
- self._start_times[f"chain_{step_index}"] = start_time
909
-
910
- step = {
911
- "step_type": "chain_start",
912
- "tool_name": chain_name,
913
- "args_excerpt": args_excerpt,
914
- "outcome": "",
915
- "ts": start_time.isoformat(),
916
- "run_id": None,
917
- "parent_run_id": None,
918
- "step_index": step_index,
919
- }
920
- self._trace.append(step)
921
-
922
- def on_chain_end(
923
- self,
924
- outputs: Dict[str, Any],
925
- **kwargs: Any,
926
- ) -> None:
927
- """Called when a chain ends running."""
928
- # 🚨 OPTIMIZED: Suppress redundant chain_end for LangGraph agents
929
- if self._is_langgraph:
930
- return
931
-
932
- outcome = self._excerpt(str(outputs))
933
-
934
- # Calculate duration_ms by matching with corresponding chain_start
935
- duration_ms = 0
936
- for i in range(len(self._trace) - 1, -1, -1):
937
- if self._trace[i].get('step_type') == 'chain_start':
938
- # Found the matching chain_start
939
- chain_key = f"chain_{i}"
940
- if chain_key in self._start_times:
941
- start_time = self._start_times[chain_key]
942
- end_time = datetime.now()
943
- duration_ms = int((end_time - start_time).total_seconds() * 1000)
944
- # Update the chain_start step with duration_ms
945
- self._trace[i]['duration_ms'] = duration_ms
946
- # Clean up start time
947
- del self._start_times[chain_key]
948
- break
949
-
950
- step = {
951
- "step_type": "chain_end",
952
- "tool_name": "",
953
- "args_excerpt": "",
954
- "outcome": outcome,
955
- "ts": datetime.now().isoformat(),
956
- "run_id": None,
957
- "parent_run_id": None,
958
- "duration_ms": duration_ms,
959
- }
960
- self._trace.append(step)
961
-
962
- def on_chain_error(
963
- self,
964
- error: BaseException,
965
- **kwargs: Any,
966
- ) -> None:
967
- """Called when a chain encounters an error."""
968
- error_msg = self._excerpt(str(error))
969
-
970
- step = {
971
- "step_type": "chain_error",
972
- "tool_name": "",
973
- "args_excerpt": "",
974
- "outcome": f"ERROR: {error_msg}",
975
- "ts": datetime.now().isoformat(),
976
- "run_id": None,
977
- "parent_run_id": None,
978
- }
979
- self._trace.append(step)
980
-
981
- def _extract_recent_message(self, inputs: Dict[str, Any]) -> str:
982
- """
983
- Extract the most recent message from LangGraph inputs to show thought progression.
984
-
985
- For LangGraph agents, inputs contain {'messages': [msg1, msg2, ...]}.
986
- Instead of showing the entire history, we extract just the last message.
987
- """
988
- try:
989
- # Check if this is a LangGraph message format
990
- if isinstance(inputs, dict) and 'messages' in inputs:
991
- messages = inputs['messages']
992
- if isinstance(messages, list) and len(messages) > 0:
993
- # Get the most recent message
994
- last_msg = messages[-1]
995
-
996
- # Extract content based on message type
997
- if hasattr(last_msg, 'content'):
998
- # LangChain message object
999
- content = last_msg.content
1000
- msg_type = getattr(last_msg, 'type', 'unknown')
1001
- return self._excerpt(f"[{msg_type}] {content}")
1002
- elif isinstance(last_msg, tuple) and len(last_msg) >= 2:
1003
- # Tuple format: (role, content)
1004
- return self._excerpt(f"[{last_msg[0]}] {last_msg[1]}")
1005
- else:
1006
- # Unknown format, convert to string
1007
- return self._excerpt(str(last_msg))
1008
-
1009
- # For non-message inputs, check if it's a list of actions/tool calls
1010
- if isinstance(inputs, list) and len(inputs) > 0:
1011
- # This might be tool call info
1012
- return self._excerpt(str(inputs[0]))
1013
-
1014
- # Fall back to original behavior for non-LangGraph agents
1015
- return self._excerpt(str(inputs))
1016
-
1017
- except Exception as e:
1018
- # On any error, fall back to original behavior
1019
- return self._excerpt(str(inputs))
1020
-
1021
- def _excerpt(self, obj: Any, max_len: int = 250, from_end: bool = False) -> str:
1022
- """
1023
- Truncate text to max_length with ellipsis.
1024
-
1025
- Args:
1026
- obj: Object to convert to string and truncate
1027
- max_len: Maximum length of excerpt
1028
- from_end: If True, take LAST max_len chars (better for system prompts).
1029
- If False, take FIRST max_len chars (better for tool args).
1030
- """
1031
- text = str(obj)
1032
- if len(text) <= max_len:
1033
- return text
1034
-
1035
- if from_end:
1036
- # Take last X chars - better for system prompts where the end contains user's actual query
1037
- return "..." + text[-(max_len-3):]
1038
- else:
1039
- # Take first X chars - better for tool inputs
1040
- return text[:max_len-3] + "..."
1041
-
1042
- def _extract_semantic_selectors(self, html_text: str) -> List[Dict[str, int]]:
1043
- """
1044
- Extract semantic HTML tags from output for grounding web browse rules.
1045
- Only extracts semantic tags (nav, header, h1, etc.) to keep trace lightweight.
1046
-
1047
- Args:
1048
- html_text: Output text that may contain HTML
1049
-
1050
- Returns:
1051
- List of {"tag": str, "count": int} sorted by count descending, or None if no HTML
1052
- """
1053
- import re
1054
-
1055
- # Quick check: does this look like HTML?
1056
- if '<' not in html_text or '>' not in html_text:
1057
- return None
1058
-
1059
- # Semantic tags we care about (prioritized for web browse agents)
1060
- semantic_tags = [
1061
- # Navigation/Structure (highest priority)
1062
- 'nav', 'header', 'footer', 'main', 'article', 'section', 'aside',
1063
-
1064
- # Headers (critical for "find headers" queries!)
1065
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
1066
-
1067
- # Interactive
1068
- 'a', 'button', 'form', 'input', 'textarea', 'select', 'label',
1069
-
1070
- # Lists (often used for navigation)
1071
- 'ul', 'ol', 'li',
1072
-
1073
- # Tables (data extraction)
1074
- 'table', 'thead', 'tbody', 'tr', 'th', 'td',
1075
-
1076
- # Media
1077
- 'img', 'video', 'audio'
1078
- ]
1079
-
1080
- # Count occurrences of each semantic tag
1081
- found_tags = {}
1082
- for tag in semantic_tags:
1083
- # Pattern: <tag ...> or <tag> (opening tags only)
1084
- pattern = f'<{tag}[\\s>]'
1085
- matches = re.findall(pattern, html_text, re.IGNORECASE)
1086
- if matches:
1087
- found_tags[tag] = len(matches)
1088
-
1089
- # Return None if no semantic tags found
1090
- if not found_tags:
1091
- return None
1092
-
1093
- # Convert to list format, sorted by count descending
1094
- # Limit to top 15 to keep trace light
1095
- result = [{"tag": tag, "count": count}
1096
- for tag, count in sorted(found_tags.items(), key=lambda x: -x[1])]
1097
- return result[:15] # Top 15 most common tags
1098
-
1099
- def set_selected_rules(self, rules: List[Dict[str, Any]]):
1100
- """Set the rules selected for this run.
1101
- Normalize incoming dicts/tuples into attribute-accessible rule objects.
1102
- """
1103
- try:
1104
- from types import SimpleNamespace
1105
- normalized = []
1106
- for item in rules or []:
1107
- # Unwrap (rule, metadata) tuples if present
1108
- if isinstance(item, tuple) and len(item) == 2:
1109
- rule_candidate = item[0]
1110
- else:
1111
- rule_candidate = item
1112
- # Convert dicts to attribute-accessible objects
1113
- if isinstance(rule_candidate, dict):
1114
- # Ensure advice_text exists
1115
- if 'advice_text' not in rule_candidate and 'advice' in rule_candidate:
1116
- rule_candidate['advice_text'] = rule_candidate.get('advice')
1117
- normalized.append(SimpleNamespace(**rule_candidate))
1118
- else:
1119
- normalized.append(rule_candidate)
1120
- self._selected_rules = normalized
1121
- except Exception:
1122
- # Fallback to raw rules
1123
- self._selected_rules = rules
1124
-
1125
- def get_trace(self) -> List[Dict[str, Any]]:
1126
- """Get the current trace (instance-level, thread-safe)."""
1127
- return self._trace.copy()
1128
-
1129
- def _inject_first_turn_override(self, prompts: List[str]) -> List[str]:
1130
- """Inject a generic first-turn override to own turn 1."""
1131
- if not prompts:
1132
- return prompts
1133
-
1134
- # Create a generic first-turn override
1135
- first_turn_override = """🚨🚨🚨 CRITICAL SYSTEM DIRECTIVE 🚨🚨🚨
1136
- ⚠️ MANDATORY: You MUST follow this exact sequence or the task will FAIL
1137
-
1138
- TURN 1 REQUIREMENT:
1139
- - Output ONLY: Action: sql_db_list_tables
1140
- Action Input: ACK_RULES:[r1]
1141
- - Do NOT use any other tools
1142
- - Do NOT perform any planning
1143
- - Do NOT output anything else
1144
-
1145
- TURN 2+ (After ACK):
1146
- - If ACK was correct, proceed with normal tools and schema
1147
- - Skip table discovery and schema introspection
1148
- - Use known tables directly
1149
-
1150
- 🚨 FAILURE TO ACK IN TURN 1 = IMMEDIATE TASK TERMINATION 🚨
1151
-
1152
- """
1153
-
1154
- # Put the injection at the VERY BEGINNING of the system prompt
1155
- modified_prompts = prompts.copy()
1156
- if modified_prompts:
1157
- modified_prompts[0] = first_turn_override + modified_prompts[0]
1158
-
1159
- self._vprint(f"[DASEIN][APPLY] Injected first-turn override")
1160
- return modified_prompts
1161
-
1162
- def _should_inject_rule(self, step_type: str, tool_name: str) -> bool:
1163
- """Determine if we should inject a rule at this step."""
1164
- # Inject for LLM starts (system-level rules) and tool starts (tool-level rules)
1165
- if step_type == "llm_start":
1166
- return True
1167
- if step_type == "tool_start":
1168
- return True
1169
- return False
1170
-
1171
- def _inject_rule_if_applicable(self, step_type: str, tool_name: str, prompts: List[str]) -> List[str]:
1172
- """Inject rules into prompts if applicable."""
1173
- if not self._should_inject_rule(step_type, tool_name):
1174
- return prompts
1175
-
1176
- # If no rules selected yet, return prompts unchanged
1177
- if not self._selected_rules:
1178
- return prompts
1179
-
1180
- # Check guard to prevent duplicate injection
1181
- # 🎯 CRITICAL: For LangGraph planning nodes, SKIP the guard - we need to inject on EVERY call
1182
- # because the same node (e.g., supervisor) can be called multiple times dynamically
1183
- use_guard = True
1184
- if hasattr(self, '_is_langgraph') and self._is_langgraph:
1185
- if step_type == 'llm_start' and hasattr(self, '_current_chain_node'):
1186
- # For planning nodes, skip guard to allow re-injection on subsequent calls
1187
- if hasattr(self, '_planning_nodes') and self._current_chain_node in self._planning_nodes:
1188
- use_guard = False
1189
-
1190
- if use_guard:
1191
- guard_key = (step_type, tool_name)
1192
- if guard_key in self._injection_guard:
1193
- return prompts
1194
-
1195
- try:
1196
- # Inject rules that target llm_start and tool_start (both go to system prompt)
1197
- system_rules = []
1198
- for rule_meta in self._selected_rules:
1199
- # Handle tuple format from select_rules: (rule, metadata)
1200
- if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
1201
- rule, metadata = rule_meta
1202
- elif isinstance(rule_meta, dict):
1203
- if 'rule' in rule_meta:
1204
- rule = rule_meta.get('rule', {})
1205
- else:
1206
- rule = rule_meta
1207
- else:
1208
- rule = rule_meta
1209
-
1210
- # Check if this rule targets system-level injection (llm_start only)
1211
- target_step_type = getattr(rule, 'target_step_type', '')
1212
-
1213
- # 🚨 CRITICAL: For LangGraph agents, only skip planning rules if agent was successfully recreated
1214
- # If recreation failed, we need to inject via callback as fallback
1215
- if step_type == 'llm_start' and hasattr(self, '_is_langgraph') and self._is_langgraph:
1216
- # Only skip if agent was actually recreated with planning rules embedded
1217
- if hasattr(self, '_agent_was_recreated') and self._agent_was_recreated:
1218
- if target_step_type in ['llm_start', 'chain_start']:
1219
- self._vprint(f"[DASEIN][CALLBACK] Skipping planning rule {getattr(rule, 'id', 'unknown')} for LangGraph agent (already injected at creation)")
1220
- continue
1221
-
1222
- # 🎯 COORDINATOR-GATED INJECTION: Only apply planning rules when executing planning-capable nodes
1223
- if target_step_type in ['llm_start', 'chain_start']:
1224
- # If we have planning nodes, only inject planning rules when we're in one of them
1225
- if hasattr(self, '_planning_nodes') and self._planning_nodes:
1226
- current_node = getattr(self, '_current_chain_node', None)
1227
- # Check if current node is in the planning nodes set
1228
- if current_node not in self._planning_nodes:
1229
- # Silently skip non-planning nodes
1230
- continue
1231
- # Injecting into planning node (logged in detailed injection log below)
1232
-
1233
- advice = getattr(rule, 'advice_text', getattr(rule, 'advice', ''))
1234
- if advice:
1235
- system_rules.append(advice)
1236
-
1237
- # Apply system-level rules if any
1238
- if system_rules and prompts:
1239
- modified_prompts = prompts.copy()
1240
- system_prompt = modified_prompts[0]
1241
-
1242
- # Combine all system rules with much stronger language
1243
- rule_injections = []
1244
- for advice in system_rules:
1245
- if "TOOL RULE:" in advice:
1246
- # Make tool rules even more explicit
1247
- rule_injections.append(f"🚨 CRITICAL TOOL OVERRIDE: {advice}")
1248
- else:
1249
- rule_injections.append(f"🚨 CRITICAL SYSTEM OVERRIDE: {advice}")
1250
-
1251
- # Build execution state context (agent-agnostic, with argument previews)
1252
- # Strategy: Show all if ≤5 calls, else show most recent 3
1253
- # Rationale: Small counts get full context; larger counts show recent to prevent duplicates
1254
- state_context = ""
1255
- if hasattr(self, '_function_calls_made') and self._function_calls_made:
1256
- state_lines = []
1257
- for func_name in sorted(self._function_calls_made.keys()):
1258
- calls = self._function_calls_made[func_name]
1259
- count = len(calls)
1260
-
1261
- # Hybrid window: show all if ≤5 calls, else show recent 3
1262
- if count <= 5:
1263
- # Show all calls with previews
1264
- state_lines.append(f" • {func_name}: called {count}x:")
1265
- for call in calls:
1266
- preview = call.get('preview', '')
1267
- if preview:
1268
- state_lines.append(f" [step {call['step']}] {preview}")
1269
- else:
1270
- state_lines.append(f" [step {call['step']}] (no args)")
1271
- else:
1272
- # Show summary + recent 3 with previews
1273
- state_lines.append(f" • {func_name}: called {count}x (most recent 3):")
1274
- for call in calls[-3:]:
1275
- preview = call.get('preview', '')
1276
- if preview:
1277
- state_lines.append(f" [step {call['step']}] {preview}")
1278
- else:
1279
- state_lines.append(f" [step {call['step']}] (no args)")
1280
-
1281
- if state_lines:
1282
- state_context = f"""
1283
- EXECUTION STATE (functions called so far in this run):
1284
- {chr(10).join(state_lines)}
1285
-
1286
- """
1287
-
1288
- combined_injection = f""" SYSTEM OVERRIDE — PLANNING TURN ONLY
1289
- These rules OVERRIDE all defaults. You MUST enforce them exactly or the task FAILS.
1290
-
1291
- Tags: AVOID (absolute ban), SKIP (force bypass), FIX (mandatory params), PREFER (ranked choice), HINT (optional).
1292
- Precedence: AVOID/SKIP > FIX > PREFER > HINT. On conflict, the higher rule ALWAYS wins.
1293
-
1294
- {state_context}Checklist (non-negotiable):
1295
- - AVOID: no banned targets under ANY condition.
1296
- - SKIP: bypass skipped steps/tools; NEVER retry them.
1297
- - FIX: all required params/settings MUST be included.
1298
- - PREFER: when multiple compliant options exist, choose the preferred—NO exceptions.
1299
- - Recovery: if a banned/skipped item already failed, IMMEDIATELY switch to a compliant alternative.
1300
-
1301
- Output Contract: Produce ONE compliant tool/function call (or direct answer if none is needed).
1302
- NO reasoning, NO justification, NO markdown.
1303
-
1304
- Rules to Enforce:
1305
-
1306
-
1307
- {chr(10).join(rule_injections)}
1308
-
1309
-
1310
- """
1311
- # Put the injection at the VERY BEGINNING of the system prompt
1312
- modified_prompts[0] = combined_injection + system_prompt
1313
-
1314
- # Add to guard (only if we're using the guard)
1315
- if use_guard:
1316
- self._injection_guard.add(guard_key)
1317
-
1318
- # Log the complete injection for debugging
1319
- # Compact injection summary
1320
- if hasattr(self, '_is_langgraph') and self._is_langgraph:
1321
- # LangGraph: show node name
1322
- func_count = len(self._function_calls_made) if hasattr(self, '_function_calls_made') and state_context else 0
1323
- node_name = getattr(self, '_current_chain_node', 'unknown')
1324
- print(f"[DASEIN] 🎯 Injecting {len(system_rules)} rule(s) into {node_name} | State: {func_count} functions tracked")
1325
- else:
1326
- # LangChain: simpler logging without node name
1327
- print(f"[DASEIN] 🎯 Injecting {len(system_rules)} rule(s) into agent")
1328
-
1329
- return modified_prompts
1330
-
1331
- except Exception as e:
1332
- self._vprint(f"[DASEIN][APPLY] Injection failed: {e}")
1333
-
1334
- return prompts
1335
-
1336
- def _inject_tool_rule_if_applicable(self, step_type: str, tool_name: str, input_str: str) -> str:
1337
- """Inject rules into tool input if applicable."""
1338
- if not self._should_inject_rule(step_type, tool_name):
1339
- return input_str
1340
-
1341
- # If no rules selected yet, return input unchanged
1342
- if not self._selected_rules:
1343
- return input_str
1344
-
1345
- # Check guard to prevent duplicate injection
1346
- guard_key = (step_type, tool_name)
1347
- if guard_key in self._injection_guard:
1348
- return input_str
1349
-
1350
- try:
1351
- # Inject rules that target tool_start
1352
- tool_rules = []
1353
- for rule_meta in self._selected_rules:
1354
- # Handle tuple format from select_rules: (rule, metadata)
1355
- if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
1356
- rule, metadata = rule_meta
1357
- else:
1358
- rule = rule_meta
1359
- metadata = {}
1360
-
1361
- # Only apply rules that target tool_start
1362
- if rule.target_step_type == "tool_start":
1363
- tool_rules.append(rule)
1364
- self._vprint(f"[DASEIN][APPLY] Tool rule: {rule.advice_text[:100]}...")
1365
-
1366
- if tool_rules:
1367
- # Apply tool-level rule injection
1368
- modified_input = self._apply_tool_rules(input_str, tool_rules)
1369
- self._injection_guard.add(guard_key)
1370
- return modified_input
1371
- else:
1372
- return input_str
1373
-
1374
- except Exception as e:
1375
- self._vprint(f"[DASEIN][APPLY] Error injecting tool rules: {e}")
1376
- return input_str
1377
-
1378
- def _apply_tool_rules(self, input_str: str, rules: List) -> str:
1379
- """Apply tool-level rules to modify the input string."""
1380
- modified_input = input_str
1381
-
1382
- for rule in rules:
1383
- try:
1384
- # Apply the rule's advice to modify the tool input
1385
- if "strip" in rule.advice_text.lower() and "fence" in rule.advice_text.lower():
1386
- # Strip markdown code fences
1387
- import re
1388
- # Remove ```sql...``` or ```...``` patterns
1389
- modified_input = re.sub(r'```(?:sql)?\s*(.*?)\s*```', r'\1', modified_input, flags=re.DOTALL)
1390
- self._vprint(f"[DASEIN][APPLY] Stripped code fences from tool input")
1391
- elif "strip" in rule.advice_text.lower() and "whitespace" in rule.advice_text.lower():
1392
- # Strip leading/trailing whitespace
1393
- modified_input = modified_input.strip()
1394
- self._vprint(f"[DASEIN][APPLY] Stripped whitespace from tool input")
1395
- # Add more rule types as needed
1396
-
1397
- except Exception as e:
1398
- self._vprint(f"[DASEIN][APPLY] Error applying tool rule: {e}")
1399
- continue
1400
-
1401
- return modified_input
1402
-
1403
- def _handle_tool_executor_start(
1404
- self,
1405
- serialized: Dict[str, Any],
1406
- inputs: Dict[str, Any],
1407
- **kwargs: Any,
1408
- ) -> None:
1409
- """Handle tool executor start - bridge from chain_start to tool_start."""
1410
- self._vprint(f"[DASEIN][CALLBACK] tool_start (from chain_start)")
1411
-
1412
- # Extract tool information from inputs
1413
- tool_name = "unknown"
1414
- tool_input = ""
1415
-
1416
- if isinstance(inputs, dict):
1417
- if "tool" in inputs:
1418
- tool_name = inputs["tool"]
1419
- elif "tool_name" in inputs:
1420
- tool_name = inputs["tool_name"]
1421
-
1422
- if "tool_input" in inputs:
1423
- tool_input = str(inputs["tool_input"])
1424
- elif "input" in inputs:
1425
- tool_input = str(inputs["input"])
1426
- else:
1427
- tool_input = str(inputs)
1428
- else:
1429
- tool_input = str(inputs)
1430
-
1431
- self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}")
1432
- self._vprint(f"[DASEIN][CALLBACK] Input: {tool_input[:100]}...")
1433
-
1434
- # Check if we have tool_start rules that cover this tool
1435
- tool_rules = [rule for rule in self._selected_rules if rule.target_step_type == "tool_start"]
1436
- covered_rules = [rule for rule in tool_rules if self._rule_covers_tool(rule, tool_name, tool_input)]
1437
-
1438
- if covered_rules:
1439
- self._vprint(f"[DASEIN][APPLY] tool_start: {len(covered_rules)} rules cover this tool call")
1440
- # Fire micro-turn for rule application
1441
- modified_input = self._fire_micro_turn_for_tool_rules(covered_rules, tool_name, tool_input)
1442
- else:
1443
- self._vprint(f"[DASEIN][APPLY] tool_start: no rules cover this tool call")
1444
- modified_input = tool_input
1445
-
1446
- args_excerpt = self._excerpt(modified_input)
1447
-
1448
- step = {
1449
- "step_type": "tool_start",
1450
- "tool_name": tool_name,
1451
- "args_excerpt": args_excerpt,
1452
- "outcome": "",
1453
- "ts": datetime.now().isoformat(),
1454
- "run_id": kwargs.get("run_id"),
1455
- "parent_run_id": kwargs.get("parent_run_id"),
1456
- }
1457
- self._trace.append(step)
1458
-
1459
- def _rule_covers_tool(self, rule, tool_name: str, tool_input: str) -> bool:
1460
- """Check if a rule covers the given tool call."""
1461
- try:
1462
- # Check if rule references this tool
1463
- if hasattr(rule, 'references') and rule.references:
1464
- if hasattr(rule.references, 'tools') and rule.references.tools:
1465
- if tool_name not in rule.references.tools:
1466
- return False
1467
-
1468
- # Check trigger patterns if they exist
1469
- if hasattr(rule, 'trigger_pattern') and rule.trigger_pattern:
1470
- # For now, assume all tool_start rules cover their referenced tools
1471
- # This can be made more sophisticated later
1472
- pass
1473
-
1474
- return True
1475
- except Exception as e:
1476
- self._vprint(f"[DASEIN][COVERAGE] Error checking rule coverage: {e}")
1477
- return False
1478
-
1479
- def _fire_micro_turn_for_tool_rules(self, rules, tool_name: str, tool_input: str) -> str:
1480
- """Fire a micro-turn LLM call to apply tool rules."""
1481
- try:
1482
- # Use the first rule for now (can be extended to handle multiple rules)
1483
- rule = rules[0]
1484
- rule_id = getattr(rule, 'id', 'unknown')
1485
-
1486
- self._vprint(f"[DASEIN][MICROTURN] rule_id={rule_id} tool={tool_name}")
1487
-
1488
- # Create micro-turn prompt
1489
- micro_turn_prompt = self._create_micro_turn_prompt(rule, tool_name, tool_input)
1490
-
1491
- # Fire actual micro-turn LLM call
1492
- modified_input = self._execute_micro_turn_llm_call(micro_turn_prompt, tool_input)
1493
-
1494
- # Store the modified input for retrieval during tool execution
1495
- input_key = f"{tool_name}:{hash(tool_input)}"
1496
- _MODIFIED_TOOL_INPUTS[input_key] = modified_input
1497
-
1498
- self._vprint(f"[DASEIN][MICROTURN] Applied rule {rule_id}: {str(tool_input)[:50]}... -> {str(modified_input)[:50]}...")
1499
-
1500
- return modified_input
1501
-
1502
- except Exception as e:
1503
- self._vprint(f"[DASEIN][MICROTURN] Error in micro-turn: {e}")
1504
- return tool_input
1505
-
1506
- def _create_micro_turn_prompt(self, rule, tool_name: str, tool_input: str) -> str:
1507
- """Create the micro-turn prompt for rule application."""
1508
- advice = getattr(rule, 'advice', '')
1509
- return f"""Apply this rule to the tool input:
1510
-
1511
- Rule: {advice}
1512
- Tool: {tool_name}
1513
- Current Input: {tool_input}
1514
-
1515
- Output only the corrected tool input:"""
1516
-
1517
- def _execute_micro_turn_llm_call(self, prompt: str, original_input: str) -> str:
1518
- """Execute the actual micro-turn LLM call."""
1519
- try:
1520
- if not self._llm:
1521
- self._vprint(f"[DASEIN][MICROTURN] No LLM available for micro-turn call")
1522
- return original_input
1523
-
1524
- self._vprint(f"[DASEIN][MICROTURN] Executing micro-turn LLM call")
1525
- self._vprint(f"[DASEIN][MICROTURN] Prompt: {prompt[:200]}...")
1526
-
1527
- # Make the micro-turn LLM call
1528
- # Create a simple message list for the LLM
1529
- messages = [{"role": "user", "content": prompt}]
1530
-
1531
- # Call the LLM
1532
- response = self._llm.invoke(messages)
1533
-
1534
- # Extract the response content
1535
- if hasattr(response, 'content'):
1536
- modified_input = response.content.strip()
1537
- elif isinstance(response, str):
1538
- modified_input = response.strip()
1539
- else:
1540
- modified_input = str(response).strip()
1541
-
1542
- self._vprint(f"[DASEIN][MICROTURN] LLM response: {modified_input[:100]}...")
1543
-
1544
- # 🚨 CRITICAL: Parse JSON responses with markdown fences
1545
- if modified_input.startswith('```json') or modified_input.startswith('```'):
1546
- try:
1547
- # Extract JSON from markdown fences
1548
- import re
1549
- import json
1550
- json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', modified_input, re.DOTALL)
1551
- if json_match:
1552
- json_str = json_match.group(1)
1553
- parsed_json = json.loads(json_str)
1554
- # Convert back to the expected format
1555
- if isinstance(parsed_json, dict) and 'name' in parsed_json and 'args' in parsed_json:
1556
- modified_input = parsed_json
1557
- self._vprint(f"[DASEIN][MICROTURN] Parsed JSON from markdown fences: {parsed_json}")
1558
- else:
1559
- self._vprint(f"[DASEIN][MICROTURN] JSON doesn't have expected structure, using as-is")
1560
- else:
1561
- self._vprint(f"[DASEIN][MICROTURN] Could not extract JSON from markdown fences")
1562
- except Exception as e:
1563
- self._vprint(f"[DASEIN][MICROTURN] Error parsing JSON: {e}")
1564
-
1565
- # Validate the response - only fallback if completely empty
1566
- if not modified_input:
1567
- self._vprint(f"[DASEIN][MICROTURN] LLM response empty, using original input")
1568
- return original_input
1569
-
1570
- return modified_input
1571
-
1572
- except Exception as e:
1573
- self._vprint(f"[DASEIN][MICROTURN] Error executing micro-turn LLM call: {e}")
1574
- return original_input
1575
-
1576
-
1577
- def get_trace() -> List[Dict[str, Any]]:
1578
- """
1579
- DEPRECATED: Legacy function for backward compatibility.
1580
- Get the current trace from active CognateProxy instances.
1581
-
1582
- Returns:
1583
- List of trace step dictionaries (empty if no active traces)
1584
- """
1585
- # Try to get trace from active CognateProxy instances
1586
- try:
1587
- import gc
1588
- for obj in gc.get_objects():
1589
- if hasattr(obj, '_last_run_trace') and obj._last_run_trace:
1590
- return obj._last_run_trace.copy()
1591
- if hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, '_trace'):
1592
- return obj._callback_handler._trace.copy()
1593
- except Exception:
1594
- pass
1595
-
1596
- return [] # Return empty list if no trace found
1597
-
1598
-
1599
- def get_modified_tool_input(tool_name: str, original_input: str) -> str:
1600
- """
1601
- Get the modified tool input if it exists.
1602
-
1603
- Args:
1604
- tool_name: Name of the tool
1605
- original_input: Original tool input
1606
-
1607
- Returns:
1608
- Modified tool input if available, otherwise original input
1609
- """
1610
- input_key = f"{tool_name}:{hash(original_input)}"
1611
- return _MODIFIED_TOOL_INPUTS.get(input_key, original_input)
1612
-
1613
-
1614
- def clear_modified_tool_inputs():
1615
- """Clear all modified tool inputs."""
1616
- global _MODIFIED_TOOL_INPUTS
1617
- _MODIFIED_TOOL_INPUTS.clear()
1618
-
1619
-
1620
- def clear_trace() -> None:
1621
- """
1622
- DEPRECATED: Legacy function for backward compatibility.
1623
- Clear traces in active CognateProxy instances.
1624
- """
1625
- # Try to clear traces in active CognateProxy instances
1626
- try:
1627
- import gc
1628
- for obj in gc.get_objects():
1629
- if hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, 'reset_run_state'):
1630
- obj._callback_handler.reset_run_state()
1631
- except Exception:
1632
- pass # Ignore if not available
1633
-
1634
-
1635
- def print_trace(max_chars: int = 240, only: tuple[str, ...] | None = None, suppress: tuple[str, ...] = ("chain_end",), show_tree: bool = True, show_summary: bool = True) -> None:
1636
- """
1637
- Print a compact fixed-width table of the trace with tree-like view and filtering.
1638
-
1639
- Args:
1640
- max_chars: Maximum characters per line (default 240)
1641
- only: Filter by step_type if provided (e.g., ("llm_start", "llm_end"))
1642
- suppress: Suppress any step_type in this tuple (default: ("chain_end",))
1643
- show_tree: If True, left-pad args_excerpt by 2*depth spaces for tree-like view
1644
- show_summary: If True, show step_type counts and deduped rows summary
1645
- """
1646
- # Try to get trace from active CognateProxy instances
1647
- trace = None
1648
- try:
1649
- # Import here to avoid circular imports
1650
- from dasein.api import _global_cognate_proxy
1651
- if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_wrapped_llm') and _global_cognate_proxy._wrapped_llm:
1652
- trace = _global_cognate_proxy._wrapped_llm.get_trace()
1653
- except:
1654
- pass
1655
-
1656
- if not trace:
1657
- trace = get_trace() # Use the updated get_trace() function
1658
-
1659
- # If global trace is empty, try to get it from the last completed run
1660
- if not trace:
1661
- # Try to get trace from any active CognateProxy instances
1662
- try:
1663
- import gc
1664
- for obj in gc.get_objects():
1665
- # Look for CognateProxy instances with captured traces
1666
- if hasattr(obj, '_last_run_trace') and obj._last_run_trace:
1667
- trace = obj._last_run_trace
1668
- print(f"[DASEIN][TRACE] Retrieved trace from CognateProxy: {len(trace)} steps")
1669
- break
1670
- # Fallback: try callback handler
1671
- elif hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, 'get_trace'):
1672
- potential_trace = obj._callback_handler.get_trace()
1673
- if potential_trace:
1674
- trace = potential_trace
1675
- print(f"[DASEIN][TRACE] Retrieved trace from callback handler: {len(trace)} steps")
1676
- break
1677
- except Exception as e:
1678
- pass
1679
-
1680
- if not trace:
1681
- print("No trace data available.")
1682
- return
1683
-
1684
- # Print execution state if available
1685
- try:
1686
- from dasein.api import _global_cognate_proxy
1687
- if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_callback_handler'):
1688
- handler = _global_cognate_proxy._callback_handler
1689
- if hasattr(handler, '_function_calls_made') and handler._function_calls_made:
1690
- print("\n" + "=" * 80)
1691
- print("EXECUTION STATE (Functions Called During Run):")
1692
- print("=" * 80)
1693
- for func_name in sorted(handler._function_calls_made.keys()):
1694
- calls = handler._function_calls_made[func_name]
1695
- count = len(calls)
1696
- print(f" • {func_name}: called {count}x")
1697
- # Hybrid window: show all if ≤5, else show most recent 3 (matches injection logic)
1698
- if count <= 5:
1699
- # Show all calls
1700
- for call in calls:
1701
- preview = call.get('preview', '(no preview)')
1702
- if len(preview) > 80:
1703
- preview = preview[:80] + '...'
1704
- print(f" [step {call['step']}] {preview}")
1705
- else:
1706
- # Show recent 3
1707
- print(f" ... (showing most recent 3 of {count}):")
1708
- for call in calls[-3:]:
1709
- preview = call.get('preview', '(no preview)')
1710
- if len(preview) > 80:
1711
- preview = preview[:80] + '...'
1712
- print(f" [step {call['step']}] {preview}")
1713
- print("=" * 80 + "\n")
1714
- except Exception as e:
1715
- pass # Silently skip if state not available
1716
-
1717
- # Filter by step_type if only is provided
1718
- filtered_trace = trace
1719
- if only:
1720
- filtered_trace = [step for step in trace if step.get("step_type") in only]
1721
-
1722
- # Suppress any step_type in suppress tuple
1723
- if suppress:
1724
- filtered_trace = [step for step in filtered_trace if step.get("step_type") not in suppress]
1725
-
1726
- if not filtered_trace:
1727
- print("No trace data matching filter criteria.")
1728
- return
1729
-
1730
- # Build depth map from parent_run_id
1731
- depth_map = {}
1732
- for step in filtered_trace:
1733
- run_id = step.get("run_id")
1734
- parent_run_id = step.get("parent_run_id")
1735
-
1736
- if run_id is None or parent_run_id is None or parent_run_id not in depth_map:
1737
- depth_map[run_id] = 0
1738
- else:
1739
- depth_map[run_id] = depth_map[parent_run_id] + 1
1740
-
1741
- # Calculate column widths based on max_chars
1742
- # Reserve space for: # (3), step_type (15), tool_name (25), separators (6)
1743
- available_width = max_chars - 3 - 15 - 25 - 6
1744
- excerpt_width = available_width // 2
1745
- outcome_width = available_width - excerpt_width
1746
-
1747
- # Print header
1748
- print(f"{'#':<3} {'step_type':<15} {'tool_name':<25} {'args_excerpt':<{excerpt_width}} {'outcome':<{outcome_width}}")
1749
- print("-" * max_chars)
1750
-
1751
- # Print each step
1752
- for i, step in enumerate(filtered_trace, 1):
1753
- step_type = step.get("step_type", "")[:15]
1754
- tool_name = str(step.get("tool_name", ""))[:25]
1755
- args_excerpt = step.get("args_excerpt", "")
1756
- outcome = step.get("outcome", "")
1757
-
1758
- # Apply tree indentation if show_tree is True
1759
- if show_tree:
1760
- run_id = step.get("run_id")
1761
- depth = depth_map.get(run_id, 0)
1762
- args_excerpt = " " * depth + args_excerpt
1763
-
1764
- # Truncate to fit column widths
1765
- args_excerpt = args_excerpt[:excerpt_width]
1766
- outcome = outcome[:outcome_width]
1767
-
1768
- print(f"{i:<3} {step_type:<15} {tool_name:<25} {args_excerpt:<{excerpt_width}} {outcome:<{outcome_width}}")
1769
-
1770
- # Show summary if requested
1771
- if show_summary:
1772
- print("\n" + "=" * max_chars)
1773
-
1774
- # Count steps by step_type
1775
- step_counts = {}
1776
- for step in filtered_trace:
1777
- step_type = step.get("step_type", "unknown")
1778
- step_counts[step_type] = step_counts.get(step_type, 0) + 1
1779
-
1780
- print("Step counts:")
1781
- for step_type, count in sorted(step_counts.items()):
1782
- print(f" {step_type}: {count}")
1783
-
1784
- # Add compact function call summary
1785
- try:
1786
- from dasein.api import _global_cognate_proxy
1787
- if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_callback_handler'):
1788
- handler = _global_cognate_proxy._callback_handler
1789
- if hasattr(handler, '_function_calls_made') and handler._function_calls_made:
1790
- print("\nFunction calls:")
1791
- for func_name in sorted(handler._function_calls_made.keys()):
1792
- count = len(handler._function_calls_made[func_name])
1793
- print(f" {func_name}: {count}")
1794
- except Exception:
1795
- pass
1796
-
1797
- # Count deduped rows skipped (steps that were filtered out)
1798
- total_steps = len(trace)
1799
- shown_steps = len(filtered_trace)
1800
- skipped_steps = total_steps - shown_steps
1801
-
1802
- if skipped_steps > 0:
1803
- print(f"Deduped rows skipped: {skipped_steps}")
1
+ """
2
+ Trace capture functionality for Dasein.
3
+ """
4
+
5
+ # Suppress third-party warnings triggered by pipecleaner dependencies
6
+ import warnings
7
+ warnings.filterwarnings('ignore', category=FutureWarning, message='.*torch.distributed.reduce_op.*')
8
+ warnings.filterwarnings('ignore', category=DeprecationWarning, message='.*Importing chat models from langchain.*')
9
+
10
+ import hashlib
11
+ from typing import Any, Dict, List, Optional, Union
12
+ from datetime import datetime
13
+ from langchain_core.callbacks.base import BaseCallbackHandler
14
+ from langchain_core.callbacks.manager import CallbackManagerForToolRun
15
+ from langchain_core.tools import BaseTool
16
+
17
+
18
+ # ============================================================================
19
+ # VERBOSE LOGGING HELPER
20
+ # ============================================================================
21
+
22
+ def _vprint(message: str, verbose: bool = False, force: bool = False):
23
+ """
24
+ Helper function for verbose printing.
25
+
26
+ Args:
27
+ message: Message to print
28
+ verbose: Whether verbose mode is enabled
29
+ force: If True, always print regardless of verbose setting
30
+ """
31
+ if force or verbose:
32
+ print(message)
33
+
34
+
35
+ # DEPRECATED: Global trace store removed for thread-safety
36
+ # Traces are now stored instance-level in DaseinCallbackHandler._trace
37
+ # _TRACE: List[Dict[str, Any]] = []
38
+
39
+ # Hook cache for agent fingerprinting
40
+ _HOOK_CACHE: Dict[str, Any] = {}
41
+
42
+ # Store for modified tool inputs
43
+ _MODIFIED_TOOL_INPUTS: Dict[str, str] = {}
44
+
45
+
46
+ class DaseinToolWrapper(BaseTool):
47
+ """Wrapper for tools that applies micro-turn modifications."""
48
+
49
+ name: str = ""
50
+ description: str = ""
51
+ original_tool: Any = None
52
+ callback_handler: Any = None
53
+
54
+ def __init__(self, original_tool, callback_handler=None, verbose: bool = False):
55
+ super().__init__(
56
+ name=original_tool.name,
57
+ description=original_tool.description
58
+ )
59
+ self.original_tool = original_tool
60
+ self.callback_handler = callback_handler
61
+ self._verbose = verbose
62
+
63
+ def _vprint(self, message: str, force: bool = False):
64
+ """Helper for verbose printing."""
65
+ _vprint(message, self._verbose, force)
66
+
67
+ def _run(self, *args, **kwargs):
68
+ """Run the tool with micro-turn injection at execution level."""
69
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] _run called for {self.name} - VERSION 2.0")
70
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Args: {args}")
71
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Kwargs: {kwargs}")
72
+
73
+ try:
74
+ # Get the original input
75
+ original_input = args[0] if args else ""
76
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Original input: {original_input[:100]}...")
77
+
78
+ # Apply micro-turn injection if we have rules
79
+ modified_input = self._apply_micro_turn_injection(str(original_input))
80
+
81
+ if modified_input != original_input:
82
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
83
+ # Use modified input
84
+ result = self.original_tool._run(modified_input, *args[1:], **kwargs)
85
+ else:
86
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] No micro-turn injection applied for {self.name}")
87
+ # Use original input
88
+ result = self.original_tool._run(*args, **kwargs)
89
+
90
+ # Capture the tool output in the trace
91
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] About to capture tool output for {self.name}")
92
+ self._capture_tool_output(self.name, args, kwargs, result)
93
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Finished capturing tool output for {self.name}")
94
+
95
+ return result
96
+
97
+ except Exception as e:
98
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Exception in _run: {e}")
99
+ import traceback
100
+ traceback.print_exc()
101
+ # Still try to call the original tool
102
+ result = self.original_tool._run(*args, **kwargs)
103
+ return result
104
+
105
+ def invoke(self, input_data, config=None, **kwargs):
106
+ """Invoke the tool with micro-turn injection."""
107
+ # Get the original input
108
+ original_input = str(input_data)
109
+
110
+ # Apply micro-turn injection if we have rules
111
+ modified_input = self._apply_micro_turn_injection(original_input)
112
+
113
+ if modified_input != original_input:
114
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
115
+ # Use modified input
116
+ return self.original_tool.invoke(modified_input, config, **kwargs)
117
+ else:
118
+ # Use original input
119
+ return self.original_tool.invoke(input_data, config, **kwargs)
120
+
121
+ async def _arun(self, *args, **kwargs):
122
+ """Async run the tool with micro-turn injection at execution level."""
123
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] _arun called for {self.name} - ASYNC VERSION")
124
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Args: {args}")
125
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Kwargs: {kwargs}")
126
+
127
+ try:
128
+ # Get the original input
129
+ original_input = args[0] if args else ""
130
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Original input: {original_input[:100]}...")
131
+
132
+ # Apply micro-turn injection if we have rules
133
+ modified_input = self._apply_micro_turn_injection(str(original_input))
134
+
135
+ if modified_input != original_input:
136
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
137
+ # Use modified input
138
+ result = await self.original_tool._arun(modified_input, *args[1:], **kwargs)
139
+ else:
140
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] No micro-turn injection applied for {self.name}")
141
+ # Use original input
142
+ result = await self.original_tool._arun(*args, **kwargs)
143
+
144
+ # Capture the tool output in the trace
145
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] About to capture tool output for {self.name}")
146
+ self._capture_tool_output(self.name, args, kwargs, result)
147
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Finished capturing tool output for {self.name}")
148
+
149
+ return result
150
+
151
+ except Exception as e:
152
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Exception in _arun: {e}")
153
+ import traceback
154
+ traceback.print_exc()
155
+ # Still try to call the original tool
156
+ result = await self.original_tool._arun(*args, **kwargs)
157
+ return result
158
+
159
+ async def ainvoke(self, input_data, config=None, **kwargs):
160
+ """Async invoke the tool with micro-turn injection."""
161
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] ainvoke called for {self.name} - ASYNC VERSION")
162
+
163
+ # Get the original input
164
+ original_input = str(input_data)
165
+
166
+ # Apply micro-turn injection if we have rules
167
+ modified_input = self._apply_micro_turn_injection(original_input)
168
+
169
+ if modified_input != original_input:
170
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Applied micro-turn injection for {self.name}: {original_input[:50]}... -> {modified_input[:50]}...")
171
+ # Use modified input
172
+ result = await self.original_tool.ainvoke(modified_input, config, **kwargs)
173
+ else:
174
+ # Use original input
175
+ result = await self.original_tool.ainvoke(input_data, config, **kwargs)
176
+
177
+ return result
178
+
179
+ def _apply_micro_turn_injection(self, original_input: str) -> str:
180
+ """Apply micro-turn injection to the tool input."""
181
+ try:
182
+ # Check if we have a callback handler with rules and LLM
183
+ if not self.callback_handler:
184
+ return original_input
185
+
186
+ # Normalize selected rules into Rule objects (handle (rule, metadata) tuples)
187
+ normalized_rules = []
188
+ for rule_meta in getattr(self.callback_handler, "_selected_rules", []) or []:
189
+ if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
190
+ rule_obj, _metadata = rule_meta
191
+ else:
192
+ rule_obj = rule_meta
193
+ normalized_rules.append(rule_obj)
194
+
195
+ # Filter tool_start rules
196
+ tool_rules = [r for r in normalized_rules if getattr(r, 'target_step_type', '') == "tool_start"]
197
+
198
+ if not tool_rules:
199
+ self._vprint(f"[DASEIN][MICROTURN] No tool rules selected - skipping micro-turn for {self.name}")
200
+ return original_input
201
+
202
+ # Check if any rule covers this tool
203
+ covered_rules = [rule for rule in tool_rules
204
+ if self._rule_covers_tool(rule, self.name, original_input)]
205
+
206
+ if not covered_rules:
207
+ return original_input
208
+
209
+ # Fire micro-turn LLM call (use first matching rule)
210
+ rule = covered_rules[0]
211
+ self._vprint(f"[DASEIN][MICROTURN] rule_id={rule.id} tool={self.name}")
212
+
213
+ # Create micro-turn prompt
214
+ micro_turn_prompt = self._create_micro_turn_prompt(rule, self.name, original_input)
215
+
216
+ # Execute micro-turn LLM call
217
+ modified_input = self._execute_micro_turn_llm_call(micro_turn_prompt, original_input)
218
+
219
+ self._vprint(f"[DASEIN][MICROTURN] Applied rule {rule.id}: {str(original_input)[:50]}... -> {str(modified_input)[:50]}...")
220
+ return modified_input
221
+
222
+ except Exception as e:
223
+ self._vprint(f"[DASEIN][MICROTURN] Error in micro-turn injection: {e}")
224
+ return original_input
225
+
226
+ def _rule_covers_tool(self, rule, tool_name: str, tool_input: str) -> bool:
227
+ """Check if a rule covers this tool call."""
228
+ if not hasattr(rule, 'references') or not rule.references:
229
+ return False
230
+
231
+ # Check if the rule references this tool
232
+ tools = rule.references.get('tools', [])
233
+ return tool_name in tools
234
+
235
+ def _create_micro_turn_prompt(self, rule, tool_name: str, tool_input: str) -> str:
236
+ """Create the prompt for the micro-turn LLM call."""
237
+ return f"""You are applying a rule to fix a tool input.
238
+
239
+ Rule: {rule.advice_text}
240
+
241
+ Tool: {tool_name}
242
+ Current Input: {tool_input}
243
+
244
+ Apply the rule to fix the input. Return only the corrected input, nothing else."""
245
+
246
+ def _execute_micro_turn_llm_call(self, prompt: str, original_input: str) -> str:
247
+ """Execute the actual micro-turn LLM call."""
248
+ try:
249
+ if not self.callback_handler or not self.callback_handler._llm:
250
+ self._vprint(f"[DASEIN][MICROTURN] No LLM available for micro-turn call")
251
+ return original_input
252
+
253
+ self._vprint(f"[DASEIN][MICROTURN] Executing micro-turn LLM call")
254
+ self._vprint(f"[DASEIN][MICROTURN] Prompt: {prompt[:200]}...")
255
+
256
+ # Make the micro-turn LLM call
257
+ messages = [{"role": "user", "content": prompt}]
258
+ response = self.callback_handler._llm.invoke(messages)
259
+
260
+ # Extract the response content
261
+ if hasattr(response, 'content'):
262
+ modified_input = response.content.strip()
263
+ elif isinstance(response, str):
264
+ modified_input = response.strip()
265
+ else:
266
+ modified_input = str(response).strip()
267
+
268
+ self._vprint(f"[DASEIN][MICROTURN] LLM response: {modified_input[:100]}...")
269
+
270
+ # 🚨 CRITICAL: Parse JSON responses with markdown fences
271
+ if modified_input.startswith('```json') or modified_input.startswith('```'):
272
+ try:
273
+ # Extract JSON from markdown fences
274
+ import re
275
+ import json
276
+ json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', modified_input, re.DOTALL)
277
+ if json_match:
278
+ json_str = json_match.group(1)
279
+ parsed_json = json.loads(json_str)
280
+ # Convert back to the expected format
281
+ if isinstance(parsed_json, dict) and 'name' in parsed_json and 'args' in parsed_json:
282
+ modified_input = parsed_json
283
+ self._vprint(f"[DASEIN][MICROTURN] Parsed JSON from markdown fences: {parsed_json}")
284
+ else:
285
+ self._vprint(f"[DASEIN][MICROTURN] JSON doesn't have expected structure, using as-is")
286
+ else:
287
+ self._vprint(f"[DASEIN][MICROTURN] Could not extract JSON from markdown fences")
288
+ except Exception as e:
289
+ self._vprint(f"[DASEIN][MICROTURN] Error parsing JSON: {e}")
290
+
291
+ # Validate the response - only fallback if completely empty
292
+ if not modified_input:
293
+ self._vprint(f"[DASEIN][MICROTURN] LLM response empty, using original input")
294
+ return original_input
295
+
296
+ return modified_input
297
+
298
+ except Exception as e:
299
+ self._vprint(f"[DASEIN][MICROTURN] Error executing micro-turn LLM call: {e}")
300
+ return original_input
301
+
302
+ def _capture_tool_output(self, tool_name, args, kwargs, result):
303
+ """Capture tool output in the trace."""
304
+ try:
305
+ # Create args excerpt
306
+ args_str = str(args) if args else ""
307
+ if len(args_str) > 1000:
308
+ args_str = args_str[:1000] + "..."
309
+
310
+ # Create result excerpt (with 10k limit)
311
+ result_str = str(result) if result else ""
312
+ if len(result_str) > 10000:
313
+ result_str = result_str[:10000] + "..."
314
+
315
+ # Add tool_end step to trace
316
+ step = {
317
+ "step_type": "tool_end",
318
+ "tool_name": tool_name,
319
+ "args_excerpt": args_str,
320
+ "outcome": result_str,
321
+ "ts": datetime.now().isoformat(),
322
+ "run_id": f"tool_{id(self)}_{datetime.now().timestamp()}",
323
+ "parent_run_id": None,
324
+ }
325
+
326
+ # Add to LLM wrapper's trace if available
327
+ if self.callback_handler and hasattr(self.callback_handler, '_llm') and self.callback_handler._llm:
328
+ if hasattr(self.callback_handler._llm, '_trace'):
329
+ self.callback_handler._llm._trace.append(step)
330
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Added to LLM wrapper trace")
331
+ else:
332
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] LLM wrapper has no _trace attribute")
333
+ else:
334
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] No LLM wrapper available")
335
+
336
+ # Also add to callback handler's trace if it has one
337
+ if self.callback_handler and hasattr(self.callback_handler, '_trace'):
338
+ self.callback_handler._trace.append(step)
339
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Added to callback handler trace")
340
+
341
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Captured tool output for {tool_name}")
342
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Output length: {len(result_str)} chars")
343
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] First 200 chars: {result_str[:200]}")
344
+ if self.callback_handler and hasattr(self.callback_handler, '_trace'):
345
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Callback handler trace length after capture: {len(self.callback_handler._trace)}")
346
+
347
+ except Exception as e:
348
+ self._vprint(f"[DASEIN][TOOL_WRAPPER] Error capturing tool output: {e}")
349
+
350
+
351
+ class DaseinCallbackHandler(BaseCallbackHandler):
352
+ """
353
+ Callback handler that captures step-by-step traces and implements rule injection.
354
+ """
355
+
356
+ def __init__(self, weights=None, llm=None, is_langgraph=False, coordinator_node=None, planning_nodes=None, verbose: bool = False, agent=None, extract_tools_fn=None):
357
+ super().__init__()
358
+ self._weights = weights
359
+ self._selected_rules = [] # Rules selected for this run
360
+ self._injection_guard = set() # Prevent duplicate injections
361
+ self._last_modified_prompts = [] # Store modified prompts for LLM wrapper
362
+ self._llm = llm # Store reference to LLM for micro-turn calls
363
+ self._tool_name_by_run_id = {} # Track tool names by run_id
364
+ self._discovered_tools = set() # Track tools discovered during execution
365
+ self._wrapped_dynamic_tools = {} # Cache of wrapped dynamic tools
366
+ self._is_langgraph = is_langgraph # Flag to skip planning rule injection for LangGraph
367
+ self._run_number = 1 # Track which run this is (for microturn testing)
368
+ self._coordinator_node = coordinator_node # Coordinator node (for future targeted injection)
369
+ self._planning_nodes = planning_nodes if planning_nodes else set() # Planning-capable nodes (including subgraph children)
370
+ self._current_chain_node = None # Track current LangGraph node
371
+ self._agent_was_recreated = False # Track if agent was successfully recreated
372
+ self._function_calls_made = {} # Track function calls: {function_name: [{'step': N, 'ts': timestamp}]}
373
+ self._trace = [] # Instance-level trace storage (not global) for thread-safety
374
+ self._verbose = verbose
375
+ self._start_times = {} # Track start times for duration calculation: {step_index: datetime}
376
+ self._agent = agent # CRITICAL: Reference to agent for runtime tool extraction
377
+ self._extract_tools_fn = extract_tools_fn # Function to extract tools
378
+ self._runtime_tools_extracted = False # Flag to extract tools only once during execution
379
+ self._compiled_tools_metadata = [] # Store extracted tools
380
+ self._pipecleaner_embedding_model = None # Cache embedding model for this run
381
+ self._current_tool_name = None # Track currently executing tool for hotpath deduplication
382
+
383
+ # Generate stable run_id for corpus deduplication
384
+ import uuid
385
+ self.run_id = str(uuid.uuid4())
386
+
387
+ self._vprint(f"[DASEIN][CALLBACK] Initialized callback handler (LangGraph: {is_langgraph}, run_id: {self.run_id[:8]})")
388
+ if coordinator_node:
389
+ self._vprint(f"[DASEIN][CALLBACK] Coordinator: {coordinator_node}")
390
+ if planning_nodes:
391
+ self._vprint(f"[DASEIN][CALLBACK] Planning nodes: {planning_nodes}")
392
+ self._vprint(f"[DASEIN][CALLBACK] Dynamic tool detection enabled (tools discovered at runtime)")
393
+
394
+ def _vprint(self, message: str, force: bool = False):
395
+ """Helper for verbose printing."""
396
+ _vprint(message, self._verbose, force)
397
+
398
+ def reset_run_state(self):
399
+ """Reset state that should be cleared between runs."""
400
+ self._function_calls_made = {}
401
+ self._injection_guard = set()
402
+ self._trace = [] # Clear instance trace
403
+ self._start_times = {} # Clear start times
404
+ self._run_number = getattr(self, '_run_number', 1) + 1 # Increment run number
405
+ self._vprint(f"[DASEIN][CALLBACK] Reset run state (trace, function calls, injection guard, and start times cleared) - now on RUN {self._run_number}")
406
+
407
+ def get_compiled_tools_summary(self):
408
+ """Return 1-line summary of extracted tools."""
409
+ if not self._compiled_tools_metadata:
410
+ return None
411
+ # Group by node
412
+ by_node = {}
413
+ for tool in self._compiled_tools_metadata:
414
+ node = tool.get('node', 'unknown')
415
+ if node not in by_node:
416
+ by_node[node] = []
417
+ by_node[node].append(tool['name'])
418
+ # Format as: node1:[tool1,tool2] node2:[tool3]
419
+ parts = [f"{node}:[{','.join(tools)}]" for node, tools in sorted(by_node.items())]
420
+ return f"{len(self._compiled_tools_metadata)} tools extracted: {' '.join(parts)}"
421
+
422
+ def _patch_tools_for_node(self, node_name: str):
423
+ """
424
+ Patch tool objects for a specific node when they're discovered at runtime.
425
+
426
+ Called from on_llm_start when tools are detected for a node.
427
+ """
428
+ try:
429
+ print(f"\n{'='*70}")
430
+ print(f"[DASEIN][TOOL_PATCH] 🔧 Patching tools for node: {node_name}")
431
+ print(f"{'='*70}")
432
+
433
+ from .wrappers import patch_tool_instance
434
+
435
+ # Track patched tools to avoid double-patching
436
+ if not hasattr(self, '_patched_tools'):
437
+ self._patched_tools = set()
438
+ print(f"[DASEIN][TOOL_PATCH] Initialized patched tools tracker")
439
+
440
+ # Find the actual tool objects for this node in the agent graph
441
+ print(f"[DASEIN][TOOL_PATCH] Searching for tool objects in node '{node_name}'...")
442
+ tool_objects = self._find_tool_objects_for_node(node_name)
443
+
444
+ if not tool_objects:
445
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ No tool objects found for node '{node_name}'")
446
+ print(f"{'='*70}\n")
447
+ return
448
+
449
+ print(f"[DASEIN][TOOL_PATCH] Found {len(tool_objects)} tool object(s)")
450
+
451
+ # Patch each tool
452
+ patched_count = 0
453
+ for i, tool_obj in enumerate(tool_objects, 1):
454
+ tool_name = getattr(tool_obj, 'name', 'unknown')
455
+ tool_type = type(tool_obj).__name__
456
+ tool_id = f"{node_name}:{tool_name}"
457
+
458
+ print(f"[DASEIN][TOOL_PATCH] [{i}/{len(tool_objects)}] Tool: '{tool_name}' (type: {tool_type})")
459
+
460
+ if tool_id in self._patched_tools:
461
+ print(f"[DASEIN][TOOL_PATCH] ⏭️ Already patched, skipping")
462
+ else:
463
+ print(f"[DASEIN][TOOL_PATCH] 🔨 Patching...")
464
+ if patch_tool_instance(tool_obj, self):
465
+ self._patched_tools.add(tool_id)
466
+ patched_count += 1
467
+ print(f"[DASEIN][TOOL_PATCH] ✅ Successfully patched '{tool_name}'")
468
+ else:
469
+ print(f"[DASEIN][TOOL_PATCH] ❌ Failed to patch '{tool_name}'")
470
+
471
+ print(f"[DASEIN][TOOL_PATCH] Summary: Patched {patched_count}/{len(tool_objects)} tools")
472
+ print(f"[DASEIN][TOOL_PATCH] Total tools patched so far: {len(self._patched_tools)}")
473
+ print(f"{'='*70}\n")
474
+
475
+ except Exception as e:
476
+ print(f"[DASEIN][TOOL_PATCH] ERROR patching tools for node {node_name}: {e}")
477
+ import traceback
478
+ traceback.print_exc()
479
+ print(f"{'='*70}\n")
480
+
481
+ def _search_node_recursively(self, node_name: str, nodes: dict, depth: int = 0) -> list:
482
+ """Recursively search for a node by name in graphs and subgraphs."""
483
+ indent = " " * depth
484
+ tool_objects = []
485
+
486
+ for parent_name, parent_node in nodes.items():
487
+ if parent_name.startswith('__'):
488
+ continue
489
+
490
+ print(f"[DASEIN][TOOL_PATCH]{indent} Checking node: {parent_name}")
491
+ print(f"[DASEIN][TOOL_PATCH]{indent} Node type: {type(parent_node).__name__}")
492
+ print(f"[DASEIN][TOOL_PATCH]{indent} Has .data: {hasattr(parent_node, 'data')}")
493
+ if hasattr(parent_node, 'data'):
494
+ print(f"[DASEIN][TOOL_PATCH]{indent} .data type: {type(parent_node.data).__name__}")
495
+ print(f"[DASEIN][TOOL_PATCH]{indent} .data has .nodes: {hasattr(parent_node.data, 'nodes')}")
496
+
497
+ # Check if this parent has a subgraph
498
+ if hasattr(parent_node, 'data') and hasattr(parent_node.data, 'nodes'):
499
+ print(f"[DASEIN][TOOL_PATCH]{indent} Has subgraph!")
500
+ try:
501
+ subgraph = parent_node.data.get_graph()
502
+ sub_nodes = subgraph.nodes
503
+ print(f"[DASEIN][TOOL_PATCH]{indent} Subgraph nodes: {list(sub_nodes.keys())}")
504
+
505
+ # Check if target node is in this subgraph
506
+ if node_name in sub_nodes:
507
+ print(f"[DASEIN][TOOL_PATCH]{indent} ✓ Found '{node_name}' in subgraph!")
508
+ target_node = sub_nodes[node_name]
509
+ if hasattr(target_node, 'node'):
510
+ actual_node = target_node.node
511
+ tool_objects = self._extract_tools_from_node_object(actual_node)
512
+ if tool_objects:
513
+ return tool_objects
514
+
515
+ # Not found here, recurse deeper into this subgraph
516
+ print(f"[DASEIN][TOOL_PATCH]{indent} Recursing into subgraph nodes...")
517
+ tool_objects = self._search_node_recursively(node_name, sub_nodes, depth + 1)
518
+ if tool_objects:
519
+ return tool_objects
520
+
521
+ except Exception as e:
522
+ print(f"[DASEIN][TOOL_PATCH]{indent} Error: {e}")
523
+ import traceback
524
+ traceback.print_exc()
525
+ else:
526
+ print(f"[DASEIN][TOOL_PATCH]{indent} No subgraph")
527
+
528
+ return tool_objects
529
+
530
+ def _find_tool_objects_for_node(self, node_name: str):
531
+ """Find actual Python tool objects for a given node."""
532
+ tool_objects = []
533
+
534
+ try:
535
+ if not hasattr(self._agent, 'get_graph'):
536
+ print(f"[DASEIN][TOOL_PATCH] Agent has no get_graph method")
537
+ return tool_objects
538
+
539
+ graph = self._agent.get_graph()
540
+ nodes = graph.nodes
541
+ node_names = list(nodes.keys())
542
+ print(f"[DASEIN][TOOL_PATCH] Graph has {len(nodes)} nodes: {node_names}")
543
+
544
+ # Check if node_name contains a dot (subgraph notation like "research_supervisor.ConductResearch")
545
+ if '.' in node_name:
546
+ print(f"[DASEIN][TOOL_PATCH] Node is subgraph: {node_name}")
547
+ parent_name, sub_name = node_name.split('.', 1)
548
+ parent_node = nodes.get(parent_name)
549
+
550
+ if parent_node and hasattr(parent_node, 'data'):
551
+ print(f"[DASEIN][TOOL_PATCH] Found parent node, getting subgraph...")
552
+ subgraph = parent_node.data.get_graph()
553
+ sub_nodes = subgraph.nodes
554
+ print(f"[DASEIN][TOOL_PATCH] Subgraph has {len(sub_nodes)} nodes")
555
+ target_node = sub_nodes.get(sub_name)
556
+
557
+ if target_node and hasattr(target_node, 'node'):
558
+ print(f"[DASEIN][TOOL_PATCH] Found target subnode, extracting tools...")
559
+ actual_node = target_node.node
560
+ tool_objects = self._extract_tools_from_node_object(actual_node)
561
+ else:
562
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ Subnode not found or has no .node attribute")
563
+ else:
564
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ Parent node not found or has no .data attribute")
565
+ else:
566
+ # Top-level node
567
+ print(f"[DASEIN][TOOL_PATCH] Node is top-level: {node_name}")
568
+ target_node = nodes.get(node_name)
569
+
570
+ if target_node:
571
+ print(f"[DASEIN][TOOL_PATCH] Found node, checking for .node attribute...")
572
+ if hasattr(target_node, 'node'):
573
+ print(f"[DASEIN][TOOL_PATCH] Has .node attribute, extracting tools...")
574
+ actual_node = target_node.node
575
+ tool_objects = self._extract_tools_from_node_object(actual_node)
576
+ else:
577
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ Node has no .node attribute")
578
+ else:
579
+ # Not found as top-level, search in subgraphs
580
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ Node '{node_name}' not found in top-level graph")
581
+ print(f"[DASEIN][TOOL_PATCH] Searching in subgraphs...")
582
+
583
+ # Recursively search all subgraphs
584
+ tool_objects = self._search_node_recursively(node_name, nodes)
585
+
586
+ if not tool_objects:
587
+ print(f"[DASEIN][TOOL_PATCH] ⚠️ Node '{node_name}' not found in any subgraph")
588
+
589
+ except Exception as e:
590
+ print(f"[DASEIN][TOOL_PATCH] ❌ Exception while finding tools: {e}")
591
+ import traceback
592
+ traceback.print_exc()
593
+
594
+ return tool_objects
595
+
596
+ def _extract_tools_from_node_object(self, node_obj):
597
+ """Extract tool objects from a node object."""
598
+ tools = []
599
+
600
+ print(f"[DASEIN][TOOL_PATCH] Checking node_obj type: {type(node_obj).__name__}")
601
+
602
+ # Check tools_by_name
603
+ if hasattr(node_obj, 'tools_by_name'):
604
+ print(f"[DASEIN][TOOL_PATCH] ✓ Has tools_by_name with {len(node_obj.tools_by_name)} tools")
605
+ tools.extend(node_obj.tools_by_name.values())
606
+ else:
607
+ print(f"[DASEIN][TOOL_PATCH] No tools_by_name")
608
+
609
+ # Check runnable.tools
610
+ if hasattr(node_obj, 'runnable'):
611
+ print(f"[DASEIN][TOOL_PATCH] ✓ Has runnable")
612
+ if hasattr(node_obj.runnable, 'tools'):
613
+ print(f"[DASEIN][TOOL_PATCH] ✓ runnable.tools exists")
614
+ runnable_tools = node_obj.runnable.tools
615
+ if callable(runnable_tools):
616
+ print(f"[DASEIN][TOOL_PATCH] runnable.tools is callable, calling...")
617
+ try:
618
+ runnable_tools = runnable_tools()
619
+ print(f"[DASEIN][TOOL_PATCH] Got {len(runnable_tools) if isinstance(runnable_tools, list) else 1} tool(s)")
620
+ except Exception as e:
621
+ print(f"[DASEIN][TOOL_PATCH] ❌ Failed to call: {e}")
622
+ if isinstance(runnable_tools, list):
623
+ tools.extend(runnable_tools)
624
+ elif runnable_tools:
625
+ tools.append(runnable_tools)
626
+ else:
627
+ print(f"[DASEIN][TOOL_PATCH] ✗ No runnable.tools")
628
+ else:
629
+ print(f"[DASEIN][TOOL_PATCH] ✗ No runnable")
630
+
631
+ # Check bound.tools
632
+ if hasattr(node_obj, 'bound'):
633
+ print(f"[DASEIN][TOOL_PATCH] ✓ Has bound")
634
+ if hasattr(node_obj.bound, 'tools'):
635
+ print(f"[DASEIN][TOOL_PATCH] ✓ bound.tools exists")
636
+ bound_tools = node_obj.bound.tools
637
+ if isinstance(bound_tools, list):
638
+ print(f"[DASEIN][TOOL_PATCH] Got {len(bound_tools)} tool(s)")
639
+ tools.extend(bound_tools)
640
+ elif bound_tools:
641
+ print(f"[DASEIN][TOOL_PATCH] Got 1 tool")
642
+ tools.append(bound_tools)
643
+ else:
644
+ print(f"[DASEIN][TOOL_PATCH] ✗ No bound.tools")
645
+ else:
646
+ print(f"[DASEIN][TOOL_PATCH] ✗ No bound")
647
+
648
+ # Check steps
649
+ if hasattr(node_obj, 'steps'):
650
+ print(f"[DASEIN][TOOL_PATCH] ✓ Has steps ({len(node_obj.steps)})")
651
+ for i, step in enumerate(node_obj.steps):
652
+ if hasattr(step, 'tools_by_name'):
653
+ print(f"[DASEIN][TOOL_PATCH] ✓ Step {i} has tools_by_name with {len(step.tools_by_name)} tools")
654
+ tools.extend(step.tools_by_name.values())
655
+ break
656
+ else:
657
+ print(f"[DASEIN][TOOL_PATCH] ✗ No steps")
658
+
659
+ print(f"[DASEIN][TOOL_PATCH] Total tools extracted: {len(tools)}")
660
+
661
+ return tools
662
+
663
+ def on_llm_start(
664
+ self,
665
+ serialized: Dict[str, Any],
666
+ prompts: List[str],
667
+ *,
668
+ run_id: str = None,
669
+ parent_run_id: Optional[str] = None,
670
+ tags: Optional[List[str]] = None,
671
+ metadata: Optional[Dict[str, Any]] = None,
672
+ **kwargs: Any,
673
+ ) -> None:
674
+ """Called when an LLM starts running."""
675
+ model_name = serialized.get("name", "unknown") if serialized else "unknown"
676
+
677
+ # PIPECLEANER: Intercept Summary LLM calls
678
+ tools_in_call = None
679
+ if 'invocation_params' in kwargs:
680
+ tools_in_call = kwargs['invocation_params'].get('tools') or kwargs['invocation_params'].get('functions')
681
+
682
+ if tools_in_call:
683
+ tool_names = [t.get('name') or t.get('function', {}).get('name', 'unknown') for t in tools_in_call]
684
+
685
+ if 'Summary' in tool_names:
686
+ # NOTE: Deduplication now happens in the HOT PATH (monkey-patched LLM methods)
687
+ # This callback is just for tracking, not deduplication
688
+ pass
689
+
690
+ if False and 'Summary' in tool_names: # DISABLED: Deduplication moved to hotpath
691
+ # Check if run-scoped corpus is enabled (has filter search rules)
692
+ has_filter_rules = False
693
+ if hasattr(self, '_selected_rules'):
694
+ from .pipecleaner import _find_filter_search_rules
695
+ filter_rules = _find_filter_search_rules('summary', self._selected_rules)
696
+ has_filter_rules = len(filter_rules) > 0
697
+
698
+ if not has_filter_rules:
699
+ # Silent fail - no corpus deduplication if no rules
700
+ pass
701
+ else:
702
+ # Only print when we actually have rules and will deduplicate
703
+ print(f"[CORPUS] 📥 Summary LLM detected with {len(prompts)} prompts")
704
+ # Re-entrancy guard: prevent nested calls from corrupting state
705
+ from contextvars import ContextVar
706
+ if not hasattr(DaseinCallbackHandler, '_in_corpus_processing'):
707
+ DaseinCallbackHandler._in_corpus_processing = ContextVar('in_corpus', default=False)
708
+ DaseinCallbackHandler._reentrancy_count = 0
709
+
710
+ if DaseinCallbackHandler._in_corpus_processing.get():
711
+ # Already processing corpus in this call stack, fail-open
712
+ DaseinCallbackHandler._reentrancy_count += 1
713
+ print(f"[CORPUS] ⚠️ Re-entrancy detected #{DaseinCallbackHandler._reentrancy_count}, skipping nested call")
714
+ return
715
+
716
+ # Set re-entrancy guard
717
+ token = DaseinCallbackHandler._in_corpus_processing.set(True)
718
+
719
+ try:
720
+ # Get or create run-scoped corpus
721
+ from .pipecleaner import get_or_create_corpus
722
+ import threading
723
+ corpus = get_or_create_corpus(self.run_id, verbose=self._verbose)
724
+
725
+ # Module-level lock for atomic snapshot/swap (shared across all instances)
726
+ if not hasattr(DaseinCallbackHandler, '_prompts_lock'):
727
+ DaseinCallbackHandler._prompts_lock = threading.Lock()
728
+
729
+ # STEP 1: Snapshot under lock (atomic read, NEVER iterate live dict)
730
+ with DaseinCallbackHandler._prompts_lock:
731
+ try:
732
+ snapshot = tuple(prompts) # Immutable snapshot, safe to iterate
733
+ except RuntimeError:
734
+ print(f"[CORPUS] ⚠️ Skipping (prompts being iterated)")
735
+ return
736
+
737
+ # STEP 2: Process outside lock (no contention)
738
+ cleaned_prompts = []
739
+ total_original_chars = 0
740
+ total_cleaned_chars = 0
741
+ total_original_tokens_est = 0
742
+ total_cleaned_tokens_est = 0
743
+
744
+ for i, prompt in enumerate(snapshot):
745
+ prompt_str = str(prompt)
746
+
747
+ # Skip if too short
748
+ if len(prompt_str) < 2500:
749
+ cleaned_prompts.append(prompt_str)
750
+ continue
751
+
752
+ # Track original
753
+ original_chars = len(prompt_str)
754
+ original_tokens_est = original_chars // 4 # Rough estimate: 4 chars/token
755
+ total_original_chars += original_chars
756
+ total_original_tokens_est += original_tokens_est
757
+
758
+ # Split: first 2000 chars (system prompt) + rest (content to dedupe)
759
+ system_part = prompt_str[:2000]
760
+ content_part = prompt_str[2000:]
761
+
762
+ # Generate unique prompt_id
763
+ import hashlib
764
+ prompt_id = f"p{i}_{hashlib.md5(content_part[:100].encode()).hexdigest()[:8]}"
765
+
766
+ # Enqueue into corpus (barrier will handle batching, blocks until ready)
767
+ # Call synchronous enqueue (will block until batch is processed, then released sequentially)
768
+ deduplicated_content = corpus.enqueue_prompt(prompt_id, content_part)
769
+
770
+ # Reassemble
771
+ cleaned_prompt = system_part + deduplicated_content
772
+
773
+ # Track cleaned
774
+ cleaned_chars = len(cleaned_prompt)
775
+ cleaned_tokens_est = cleaned_chars // 4
776
+ total_cleaned_chars += cleaned_chars
777
+ total_cleaned_tokens_est += cleaned_tokens_est
778
+
779
+ reduction_pct = 100*(original_chars-cleaned_chars)//original_chars if original_chars > 0 else 0
780
+ # Always show reduction results (key metric)
781
+ print(f"[🧹 CORPUS] Prompt {prompt_id}: {original_chars} {cleaned_chars} chars ({reduction_pct}% saved)")
782
+ cleaned_prompts.append(cleaned_prompt)
783
+
784
+ # Store token delta for later adjustment in on_llm_end
785
+ if total_original_tokens_est > 0:
786
+ tokens_saved = total_original_tokens_est - total_cleaned_tokens_est
787
+ if not hasattr(self, '_corpus_token_savings'):
788
+ self._corpus_token_savings = {}
789
+ self._corpus_token_savings[run_id] = tokens_saved
790
+ print(f"[🔬 TOKEN TRACKING] Pre-prune: {total_original_chars} chars (~{total_original_tokens_est} tokens)")
791
+ print(f"[🔬 TOKEN TRACKING] Post-prune: {total_cleaned_chars} chars (~{total_cleaned_tokens_est} tokens)")
792
+ print(f"[🔬 TOKEN TRACKING] Estimated savings: ~{tokens_saved} tokens ({100*tokens_saved//total_original_tokens_est if total_original_tokens_est > 0 else 0}%)")
793
+ print(f"[🔬 TOKEN TRACKING] Stored savings for run_id={str(run_id)[:8]} to adjust on_llm_end")
794
+
795
+ # STEP 3: Atomic swap under lock (copy-on-write, no in-place mutation)
796
+ print(f"[🔬 CORPUS DEBUG] About to swap prompts - have {len(cleaned_prompts)} cleaned prompts")
797
+ with DaseinCallbackHandler._prompts_lock:
798
+ try:
799
+ print(f"[🔬 CORPUS DEBUG] Inside lock, swapping...")
800
+ # Atomic slice assignment (replaces entire contents in one operation)
801
+ prompts[:] = cleaned_prompts
802
+ # CRITICAL: Update _last_modified_prompts so DaseinLLMWrapper sees deduplicated prompts
803
+ self._last_modified_prompts = cleaned_prompts
804
+ print(f"[🔬 CORPUS] Updated _last_modified_prompts with {len(cleaned_prompts)} deduplicated prompts")
805
+ except RuntimeError as e:
806
+ print(f"[CORPUS] ⚠️ Could not swap prompts (framework collision): {e}")
807
+ except Exception as e:
808
+ print(f"[CORPUS] ⚠️ Unexpected error swapping: {e}")
809
+ import traceback
810
+ traceback.print_exc()
811
+ finally:
812
+ # Always reset re-entrancy guard
813
+ DaseinCallbackHandler._in_corpus_processing.reset(token)
814
+
815
+ # DEBUG: Print run context
816
+ # print(f"🔧 [LLM_START DEBUG] run_id: {run_id}, parent: {parent_run_id}")
817
+
818
+ # 🎯 CRITICAL: Track current node from kwargs metadata FIRST (needed for tool extraction)
819
+ if self._is_langgraph and 'metadata' in kwargs and isinstance(kwargs['metadata'], dict):
820
+ if 'langgraph_node' in kwargs['metadata']:
821
+ node_name = kwargs['metadata']['langgraph_node']
822
+ self._current_chain_node = node_name
823
+
824
+ # CRITICAL: Extract tools incrementally from each tool-bearing call
825
+ # Tools are bound node-by-node as they're invoked
826
+ if self._is_langgraph and self._agent:
827
+ # Check if THIS call has tools (signal that THIS node's tools are now bound)
828
+ tools_in_call = None
829
+ if 'invocation_params' in kwargs:
830
+ tools_in_call = kwargs['invocation_params'].get('tools') or kwargs['invocation_params'].get('functions')
831
+ elif 'tools' in kwargs:
832
+ tools_in_call = kwargs['tools']
833
+ elif 'functions' in kwargs:
834
+ tools_in_call = kwargs['functions']
835
+
836
+ if tools_in_call:
837
+ node_name = self._current_chain_node or 'unknown'
838
+
839
+ # Extract tool names from the schemas
840
+ tool_names = []
841
+ for tool in tools_in_call:
842
+ name = tool.get('name') or tool.get('function', {}).get('name', 'unknown')
843
+ tool_names.append(name)
844
+
845
+ # print(f"🔧 [TOOLS DETECTED] Node '{node_name}' has {len(tool_names)} tools: {tool_names}") # Commented out - too noisy
846
+
847
+ # Check if we've already extracted tools for this node
848
+ existing_nodes = {t.get('node') for t in self._compiled_tools_metadata}
849
+ if node_name not in existing_nodes:
850
+ try:
851
+ # Extract tools from this specific call (provider-resolved schemas)
852
+ for tool in tools_in_call:
853
+ tool_meta = {
854
+ 'name': tool.get('name') or tool.get('function', {}).get('name', 'unknown'),
855
+ 'description': tool.get('description') or tool.get('function', {}).get('description', ''),
856
+ 'node': node_name
857
+ }
858
+
859
+ # Get args schema
860
+ if 'parameters' in tool:
861
+ tool_meta['args_schema'] = tool['parameters']
862
+ elif 'function' in tool and 'parameters' in tool['function']:
863
+ tool_meta['args_schema'] = tool['function']['parameters']
864
+ else:
865
+ tool_meta['args_schema'] = {}
866
+
867
+ self._compiled_tools_metadata.append(tool_meta)
868
+
869
+ # print(f"🔧 [TOOLS METADATA] Extracted metadata for {len(tool_names)} tools from node '{node_name}'") # Commented out - too noisy
870
+ except Exception as e:
871
+ print(f"🔧 [TOOLS ERROR] Failed to extract metadata: {e}")
872
+ pass # Silently fail
873
+ # else:
874
+ # print(f"🔧 [TOOLS SKIP] Already extracted tools for node '{node_name}'") # Commented out - too noisy
875
+
876
+ # Inject rules if applicable
877
+ modified_prompts = self._inject_rule_if_applicable("llm_start", model_name, prompts)
878
+
879
+ # Store the modified prompts for the LLM wrapper to use
880
+ self._last_modified_prompts = modified_prompts
881
+
882
+ # Note: Pipecleaner deduplication now happens at ToolExecutor level (see wrappers.py)
883
+
884
+ # 🚨 OPTIMIZED: For LangGraph, check if kwargs contains 'invocation_params' with messages
885
+ # Extract the most recent message instead of full history
886
+ # Use from_end=True to capture the END of system prompts (where user's actual query is)
887
+ if 'invocation_params' in kwargs and 'messages' in kwargs['invocation_params']:
888
+ args_excerpt = self._extract_recent_message({'messages': kwargs['invocation_params']['messages']})
889
+ else:
890
+ args_excerpt = self._excerpt(" | ".join(modified_prompts), from_end=True)
891
+
892
+ # GNN-related fields
893
+ step_index = len(self._trace)
894
+
895
+ # Track which rules triggered at this step (llm_start rules)
896
+ rule_triggered_here = []
897
+ if hasattr(self, '_selected_rules') and self._selected_rules:
898
+ for rule_meta in self._selected_rules:
899
+ if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
900
+ rule_obj, _metadata = rule_meta
901
+ else:
902
+ rule_obj = rule_meta
903
+ target_step_type = getattr(rule_obj, 'target_step_type', '')
904
+ if target_step_type in ['llm_start', 'chain_start']:
905
+ rule_triggered_here.append(getattr(rule_obj, 'id', 'unknown'))
906
+
907
+ # Record start time for duration calculation
908
+ start_time = datetime.now()
909
+ self._start_times[step_index] = start_time
910
+
911
+ step = {
912
+ "step_type": "llm_start",
913
+ "tool_name": model_name,
914
+ "args_excerpt": args_excerpt,
915
+ "outcome": "",
916
+ "ts": start_time.isoformat(),
917
+ "run_id": None,
918
+ "parent_run_id": None,
919
+ "node": self._current_chain_node, # LangGraph node name (if available)
920
+ # GNN step-level fields
921
+ "step_index": step_index,
922
+ "rule_triggered_here": rule_triggered_here,
923
+ }
924
+ self._trace.append(step)
925
+ # self._vprint(f"[DASEIN][CALLBACK] Captured llm_start: {len(_TRACE)} total steps") # Commented out - too noisy
926
+
927
+ def on_llm_end(
928
+ self,
929
+ response: Any,
930
+ **kwargs: Any,
931
+ ) -> None:
932
+ """Called when an LLM ends running."""
933
+ outcome = ""
934
+ try:
935
+ # Debug: Print ALL available data to see what we're getting
936
+ # print(f"[DEBUG] on_llm_end called")
937
+ # print(f" response type: {type(response)}")
938
+ # print(f" kwargs keys: {kwargs.keys()}")
939
+
940
+ # Try multiple extraction strategies
941
+ # Strategy 1: Standard LangChain LLMResult structure
942
+ if hasattr(response, 'generations') and response.generations:
943
+ if len(response.generations) > 0:
944
+ first_gen = response.generations[0]
945
+ if isinstance(first_gen, list) and len(first_gen) > 0:
946
+ generation = first_gen[0]
947
+ else:
948
+ generation = first_gen
949
+
950
+ # Try multiple content fields
951
+ if hasattr(generation, 'text') and generation.text:
952
+ outcome = self._excerpt(generation.text)
953
+ elif hasattr(generation, 'message'):
954
+ if hasattr(generation.message, 'content'):
955
+ outcome = self._excerpt(generation.message.content)
956
+ elif hasattr(generation.message, 'text'):
957
+ outcome = self._excerpt(generation.message.text)
958
+ elif hasattr(generation, 'content'):
959
+ outcome = self._excerpt(generation.content)
960
+ else:
961
+ outcome = self._excerpt(str(generation))
962
+
963
+ # Strategy 2: Check if response itself has content
964
+ elif hasattr(response, 'content'):
965
+ outcome = self._excerpt(response.content)
966
+
967
+ # Strategy 3: Check kwargs for output/response
968
+ elif 'output' in kwargs:
969
+ outcome = self._excerpt(str(kwargs['output']))
970
+ elif 'result' in kwargs:
971
+ outcome = self._excerpt(str(kwargs['result']))
972
+
973
+ # Fallback
974
+ if not outcome:
975
+ outcome = self._excerpt(str(response))
976
+
977
+ # Debug: Warn if still empty
978
+ if not outcome or len(outcome) == 0:
979
+ self._vprint(f"[DASEIN][CALLBACK] WARNING: on_llm_end got empty outcome!")
980
+ print(f" Response: {str(response)[:1000]}")
981
+ print(f" kwargs keys: {list(kwargs.keys())}")
982
+
983
+ except (AttributeError, IndexError, TypeError) as e:
984
+ self._vprint(f"[DASEIN][CALLBACK] Error in on_llm_end: {e}")
985
+ outcome = self._excerpt(str(response))
986
+
987
+ # # 🎯 PRINT FULL LLM OUTPUT (RAW, UNTRUNCATED) - COMMENTED OUT FOR TESTING
988
+ # node_name = getattr(self, '_current_chain_node', 'agent')
989
+ # run_number = getattr(self, '_run_number', 1)
990
+ # print(f"\n{'='*80}")
991
+ # print(f"[DASEIN][LLM_END] RUN {run_number} | Node: {node_name}")
992
+ # print(f"{'='*80}")
993
+ # print(f"FULL OUTPUT:\n{str(response)}")
994
+ # print(f"{'='*80}\n")
995
+
996
+ # 🎯 CRITICAL: Extract function calls for state tracking (agent-agnostic)
997
+ try:
998
+ if hasattr(response, 'generations') and response.generations:
999
+ first_gen = response.generations[0]
1000
+ if isinstance(first_gen, list) and len(first_gen) > 0:
1001
+ generation = first_gen[0]
1002
+ else:
1003
+ generation = first_gen
1004
+
1005
+ # Check for function_call in message additional_kwargs
1006
+ if hasattr(generation, 'message') and hasattr(generation.message, 'additional_kwargs'):
1007
+ func_call = generation.message.additional_kwargs.get('function_call')
1008
+ if func_call and isinstance(func_call, dict) and 'name' in func_call:
1009
+ func_name = func_call['name']
1010
+ step_num = len(self._trace)
1011
+
1012
+ # Extract arguments and create preview
1013
+ args_str = func_call.get('arguments', '')
1014
+ preview = ''
1015
+ if args_str and len(args_str) > 0:
1016
+ # Take first 100 chars as preview
1017
+ preview = args_str[:100].replace('\n', ' ').replace('\r', '')
1018
+ if len(args_str) > 100:
1019
+ preview += '...'
1020
+
1021
+ call_info = {
1022
+ 'step': step_num,
1023
+ 'ts': datetime.now().isoformat(),
1024
+ 'preview': preview
1025
+ }
1026
+
1027
+ if func_name not in self._function_calls_made:
1028
+ self._function_calls_made[func_name] = []
1029
+ self._function_calls_made[func_name].append(call_info)
1030
+
1031
+ # 🔥 HOTPATH: Set current tool name for next LLM call (which will be inside the tool)
1032
+ self._current_tool_name = func_name
1033
+
1034
+ self._vprint(f"[DASEIN][STATE] Tracked function call: {func_name} (count: {len(self._function_calls_made[func_name])})")
1035
+ except Exception as e:
1036
+ pass # Silently skip if function call extraction fails
1037
+
1038
+ # Extract token usage from response metadata
1039
+ input_tokens = 0
1040
+ output_tokens = 0
1041
+ try:
1042
+ # Try LangChain's standard llm_output field
1043
+ if hasattr(response, 'llm_output') and response.llm_output:
1044
+ llm_output = response.llm_output
1045
+ # Different providers use different field names
1046
+ if 'token_usage' in llm_output:
1047
+ usage = llm_output['token_usage']
1048
+ input_tokens = usage.get('prompt_tokens', 0) or usage.get('input_tokens', 0)
1049
+ output_tokens = usage.get('completion_tokens', 0) or usage.get('output_tokens', 0)
1050
+ elif 'usage_metadata' in llm_output:
1051
+ usage = llm_output['usage_metadata']
1052
+ input_tokens = usage.get('input_tokens', 0) or usage.get('prompt_tokens', 0)
1053
+ output_tokens = usage.get('output_tokens', 0) or usage.get('completion_tokens', 0)
1054
+
1055
+ if (input_tokens == 0 and output_tokens == 0) and hasattr(response, 'generations') and response.generations:
1056
+ first_gen = response.generations[0]
1057
+ if isinstance(first_gen, list) and len(first_gen) > 0:
1058
+ gen = first_gen[0]
1059
+ else:
1060
+ gen = first_gen
1061
+
1062
+ # Check message.usage_metadata (Google GenAI stores it here!)
1063
+ if hasattr(gen, 'message') and hasattr(gen.message, 'usage_metadata'):
1064
+ usage = gen.message.usage_metadata
1065
+ input_tokens = usage.get('input_tokens', 0)
1066
+ output_tokens = usage.get('output_tokens', 0)
1067
+
1068
+ # Fallback: Check generation_info
1069
+ elif hasattr(gen, 'generation_info') and gen.generation_info:
1070
+ gen_info = gen.generation_info
1071
+ if 'usage_metadata' in gen_info:
1072
+ usage = gen_info['usage_metadata']
1073
+ input_tokens = usage.get('prompt_token_count', 0) or usage.get('input_tokens', 0)
1074
+ output_tokens = usage.get('candidates_token_count', 0) or usage.get('output_tokens', 0)
1075
+
1076
+ # Check if we have stored savings from corpus deduplication and adjust tokens
1077
+ current_run_id = kwargs.get('run_id', None)
1078
+ if current_run_id and hasattr(self, '_corpus_token_savings') and current_run_id in self._corpus_token_savings:
1079
+ tokens_saved = self._corpus_token_savings[current_run_id]
1080
+ # Adjust input tokens to reflect deduplication savings
1081
+ if input_tokens > 0:
1082
+ # If provider count is much larger than saved estimate, LLM saw original prompts
1083
+ if abs(input_tokens - tokens_saved) >= input_tokens * 0.3:
1084
+ input_tokens = max(0, input_tokens - tokens_saved)
1085
+ # Clean up
1086
+ del self._corpus_token_savings[current_run_id]
1087
+
1088
+ # Log if we got tokens
1089
+ # if input_tokens > 0 or output_tokens > 0:
1090
+ # self._vprint(f"[DASEIN][TOKENS] Captured: {input_tokens} in, {output_tokens} out")
1091
+
1092
+ except Exception as e:
1093
+ # Print error for debugging
1094
+ self._vprint(f"[DASEIN][CALLBACK] Error extracting tokens: {e}")
1095
+ import traceback
1096
+ traceback.print_exc()
1097
+
1098
+ # GNN-related fields: compute tokens_delta
1099
+ step_index = len(self._trace)
1100
+ tokens_delta = 0
1101
+ # Find previous step with tokens_output to compute delta
1102
+ for prev_step in reversed(self._trace):
1103
+ if 'tokens_output' in prev_step and prev_step['tokens_output'] > 0:
1104
+ tokens_delta = output_tokens - prev_step['tokens_output']
1105
+ break
1106
+
1107
+ # Calculate duration_ms by matching with corresponding llm_start
1108
+ duration_ms = 0
1109
+ for i in range(len(self._trace) - 1, -1, -1):
1110
+ if self._trace[i].get('step_type') == 'llm_start':
1111
+ # Found the matching llm_start
1112
+ if i in self._start_times:
1113
+ start_time = self._start_times[i]
1114
+ end_time = datetime.now()
1115
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
1116
+ # Update the llm_start step with duration_ms
1117
+ self._trace[i]['duration_ms'] = duration_ms
1118
+ break
1119
+
1120
+ step = {
1121
+ "step_type": "llm_end",
1122
+ "tool_name": "",
1123
+ "args_excerpt": "",
1124
+ "outcome": self._excerpt(outcome, max_len=1000), # Truncate to 1000 chars
1125
+ "ts": datetime.now().isoformat(),
1126
+ "run_id": None,
1127
+ "parent_run_id": None,
1128
+ "tokens_input": input_tokens,
1129
+ "tokens_output": output_tokens,
1130
+ "node": self._current_chain_node, # LangGraph node name (if available)
1131
+ # GNN step-level fields
1132
+ "step_index": step_index,
1133
+ "tokens_delta": tokens_delta,
1134
+ "duration_ms": duration_ms,
1135
+ }
1136
+ self._trace.append(step)
1137
+
1138
+ def on_agent_action(
1139
+ self,
1140
+ action: Any,
1141
+ **kwargs: Any,
1142
+ ) -> None:
1143
+ """Called when an agent takes an action."""
1144
+ tool_name = getattr(action, 'tool', 'unknown')
1145
+ args_excerpt = self._excerpt(str(getattr(action, 'tool_input', '')))
1146
+ outcome = self._excerpt(str(getattr(action, 'log', '')))
1147
+
1148
+ step = {
1149
+ "step_type": "agent_action",
1150
+ "tool_name": tool_name,
1151
+ "args_excerpt": args_excerpt,
1152
+ "outcome": outcome,
1153
+ "ts": datetime.now().isoformat(),
1154
+ "run_id": None,
1155
+ "parent_run_id": None,
1156
+ }
1157
+ self._trace.append(step)
1158
+
1159
+ def on_agent_finish(
1160
+ self,
1161
+ finish: Any,
1162
+ **kwargs: Any,
1163
+ ) -> None:
1164
+ """Called when an agent finishes."""
1165
+ outcome = self._excerpt(str(getattr(finish, 'return_values', '')))
1166
+
1167
+ step = {
1168
+ "step_type": "agent_finish",
1169
+ "tool_name": None,
1170
+ "args_excerpt": "",
1171
+ "outcome": outcome,
1172
+ "ts": datetime.now().isoformat(),
1173
+ "run_id": None,
1174
+ "parent_run_id": None,
1175
+ }
1176
+ self._trace.append(step)
1177
+
1178
+ def on_tool_start(
1179
+ self,
1180
+ serialized: Dict[str, Any],
1181
+ input_str: str,
1182
+ *,
1183
+ run_id: str,
1184
+ parent_run_id: Optional[str] = None,
1185
+ tags: Optional[List[str]] = None,
1186
+ metadata: Optional[Dict[str, Any]] = None,
1187
+ inputs: Optional[Dict[str, Any]] = None,
1188
+ **kwargs: Any,
1189
+ ) -> None:
1190
+ """Called when a tool starts running.
1191
+
1192
+ This is where we detect and track dynamic tools that weren't
1193
+ statically attached to the agent at init time.
1194
+ """
1195
+ import time
1196
+ tool_name = serialized.get("name", "unknown") if serialized else "unknown"
1197
+
1198
+ # Track discovered tools for reporting
1199
+ if tool_name != "unknown" and tool_name not in self._discovered_tools:
1200
+ self._discovered_tools.add(tool_name)
1201
+ # Tool discovered and tracked (silently)
1202
+
1203
+ # Store tool name for later use in on_tool_end
1204
+ self._tool_name_by_run_id[run_id] = tool_name
1205
+
1206
+ # 🔥 HOTPATH: Track current tool for pipecleaner deduplication
1207
+ self._current_tool_name = tool_name
1208
+
1209
+ # Apply tool-level rule injection
1210
+ # self._vprint(f"[DASEIN][CALLBACK] on_tool_start called!") # Commented out - too noisy
1211
+ # self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}") # Commented out - too noisy
1212
+ # self._vprint(f"[DASEIN][CALLBACK] Input: {input_str[:100]}...") # Commented out - too noisy
1213
+ # self._vprint(f"[DASEIN][APPLY] on_tool_start: selected_rules={len(self._selected_rules)}") # Commented out - too noisy
1214
+ modified_input = self._inject_tool_rule_if_applicable("tool_start", tool_name, input_str)
1215
+
1216
+ args_excerpt = self._excerpt(modified_input)
1217
+
1218
+ # GNN-related fields: capture step-level metrics
1219
+ step_index = len(self._trace)
1220
+ tool_input_chars = len(str(input_str))
1221
+
1222
+ # Track which rules triggered at this step
1223
+ rule_triggered_here = []
1224
+ if hasattr(self, '_selected_rules') and self._selected_rules:
1225
+ for rule_meta in self._selected_rules:
1226
+ if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
1227
+ rule_obj, _metadata = rule_meta
1228
+ else:
1229
+ rule_obj = rule_meta
1230
+ if getattr(rule_obj, 'target_step_type', '') == "tool_start":
1231
+ rule_triggered_here.append(getattr(rule_obj, 'id', 'unknown'))
1232
+
1233
+ # Record start time for duration calculation (keyed by run_id for tools)
1234
+ start_time = datetime.now()
1235
+ self._start_times[run_id] = start_time
1236
+
1237
+ step = {
1238
+ "step_type": "tool_start",
1239
+ "tool_name": tool_name,
1240
+ "args_excerpt": args_excerpt,
1241
+ "outcome": "",
1242
+ "ts": start_time.isoformat(),
1243
+ "run_id": run_id,
1244
+ "parent_run_id": parent_run_id,
1245
+ "node": self._current_chain_node, # LangGraph node name (if available)
1246
+ # GNN step-level fields
1247
+ "step_index": step_index,
1248
+ "tool_input_chars": tool_input_chars,
1249
+ "rule_triggered_here": rule_triggered_here,
1250
+ }
1251
+ self._trace.append(step)
1252
+
1253
+ def on_tool_end(
1254
+ self,
1255
+ output: str,
1256
+ *,
1257
+ run_id: str,
1258
+ parent_run_id: Optional[str] = None,
1259
+ tags: Optional[List[str]] = None,
1260
+ **kwargs: Any,
1261
+ ) -> Any:
1262
+ """Called when a tool ends running."""
1263
+ import time
1264
+ # Get the tool name from the corresponding tool_start
1265
+ tool_name = self._tool_name_by_run_id.get(run_id, "unknown")
1266
+
1267
+ # Handle different output types (LangGraph may pass ToolMessage objects)
1268
+ output_str = str(output)
1269
+
1270
+ # Note: Pipecleaner deduplication happens at ToolExecutor level (see wrappers.py)
1271
+
1272
+ outcome = self._excerpt(output_str)
1273
+
1274
+ # self._vprint(f"[DASEIN][CALLBACK] on_tool_end called!") # Commented out - too noisy
1275
+ # self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}") # Commented out - too noisy
1276
+ # self._vprint(f"[DASEIN][CALLBACK] Output length: {len(output_str)} chars") # Commented out - too noisy
1277
+ # self._vprint(f"[DASEIN][CALLBACK] Outcome length: {len(outcome)} chars") # Commented out - too noisy
1278
+
1279
+ # GNN-related fields: capture tool output metrics
1280
+ step_index = len(self._trace)
1281
+ tool_output_chars = len(output_str)
1282
+
1283
+ # Estimate tool_output_items (heuristic: count lines, or rows if SQL-like)
1284
+ tool_output_items = 0
1285
+ try:
1286
+ # Try to count lines as a proxy for items
1287
+ if output_str:
1288
+ tool_output_items = output_str.count('\n') + 1
1289
+ except:
1290
+ tool_output_items = 0
1291
+
1292
+ # Calculate duration_ms using run_id to match with tool_start
1293
+ duration_ms = 0
1294
+ if run_id in self._start_times:
1295
+ start_time = self._start_times[run_id]
1296
+ end_time = datetime.now()
1297
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
1298
+ # Update the corresponding tool_start step with duration_ms
1299
+ for i in range(len(self._trace) - 1, -1, -1):
1300
+ if self._trace[i].get('step_type') == 'tool_start' and self._trace[i].get('run_id') == run_id:
1301
+ self._trace[i]['duration_ms'] = duration_ms
1302
+ break
1303
+ # Clean up start time
1304
+ del self._start_times[run_id]
1305
+
1306
+ # Extract available selectors from DOM-like output (web browse agents)
1307
+ available_selectors = None
1308
+ if tool_name in ['extract_text', 'get_elements', 'extract_hyperlinks', 'extract_content']:
1309
+ available_selectors = self._extract_semantic_selectors(output_str)
1310
+
1311
+ step = {
1312
+ "step_type": "tool_end",
1313
+ "tool_name": tool_name,
1314
+ "args_excerpt": "",
1315
+ "outcome": self._excerpt(outcome, max_len=1000), # Truncate to 1000 chars
1316
+ "ts": datetime.now().isoformat(),
1317
+ "run_id": run_id,
1318
+ "parent_run_id": parent_run_id,
1319
+ "node": self._current_chain_node, # LangGraph node name (if available)
1320
+ # GNN step-level fields
1321
+ "step_index": step_index,
1322
+ "tool_output_chars": tool_output_chars,
1323
+ "tool_output_items": tool_output_items,
1324
+ "duration_ms": duration_ms,
1325
+ }
1326
+
1327
+ # Add available_selectors only if found (keep trace light)
1328
+ if available_selectors:
1329
+ step["available_selectors"] = available_selectors
1330
+ self._trace.append(step)
1331
+
1332
+ # Clean up the stored tool name
1333
+ if run_id in self._tool_name_by_run_id:
1334
+ del self._tool_name_by_run_id[run_id]
1335
+
1336
+ # 🔥 HOTPATH: Clear current tool
1337
+ self._current_tool_name = None
1338
+
1339
+ def on_tool_error(
1340
+ self,
1341
+ error: BaseException,
1342
+ *,
1343
+ run_id: str,
1344
+ parent_run_id: Optional[str] = None,
1345
+ tags: Optional[List[str]] = None,
1346
+ **kwargs: Any,
1347
+ ) -> None:
1348
+ """Called when a tool encounters an error."""
1349
+ error_msg = self._excerpt(str(error))
1350
+
1351
+ step = {
1352
+ "step_type": "tool_error",
1353
+ "tool_name": "",
1354
+ "args_excerpt": "",
1355
+ "outcome": f"ERROR: {error_msg}",
1356
+ "ts": datetime.now().isoformat(),
1357
+ "run_id": run_id,
1358
+ "parent_run_id": parent_run_id,
1359
+ }
1360
+ self._trace.append(step)
1361
+
1362
+ def on_chain_start(
1363
+ self,
1364
+ serialized: Dict[str, Any],
1365
+ inputs: Dict[str, Any],
1366
+ **kwargs: Any,
1367
+ ) -> None:
1368
+ """Called when a chain starts running."""
1369
+ chain_name = serialized.get("name", "unknown") if serialized else "unknown"
1370
+ # self._vprint(f"[DASEIN][CALLBACK] on_chain_start called!") # Commented out - too noisy
1371
+ # self._vprint(f"[DASEIN][CALLBACK] Chain: {chain_name}") # Commented out - too noisy
1372
+
1373
+ # 🚨 OPTIMIZED: For LangGraph agents, suppress redundant chain_start events
1374
+ # LangGraph fires on_chain_start for every internal node, creating noise
1375
+ # We already capture llm_start, llm_end, tool_start, tool_end which are more meaningful
1376
+ if self._is_langgraph:
1377
+ # Track current chain node for future targeted injection
1378
+ # 🎯 CRITICAL: Extract actual node name from metadata (same as on_llm_start)
1379
+ if 'metadata' in kwargs and isinstance(kwargs['metadata'], dict):
1380
+ if 'langgraph_node' in kwargs['metadata']:
1381
+ self._current_chain_node = kwargs['metadata']['langgraph_node']
1382
+ # print(f"🔵 [NODE EXEC] {self._current_chain_node}") # Commented out - too noisy
1383
+ else:
1384
+ self._current_chain_node = chain_name
1385
+ # print(f"🔵 [NODE EXEC] {chain_name}") # Commented out - too noisy
1386
+ else:
1387
+ self._current_chain_node = chain_name
1388
+ # print(f"🔵 [NODE EXEC] {chain_name}") # Commented out - too noisy
1389
+
1390
+ # self._vprint(f"[DASEIN][CALLBACK] Suppressing redundant chain_start for LangGraph agent") # Commented out - too noisy
1391
+ # Still handle tool executors
1392
+ if chain_name in {"tools", "ToolNode", "ToolExecutor"}:
1393
+ # self._vprint(f"[DASEIN][CALLBACK] Bridging chain_start to tool_start for {chain_name}") # Commented out - too noisy
1394
+ pass
1395
+ self._handle_tool_executor_start(serialized, inputs, **kwargs)
1396
+ return
1397
+
1398
+ # For standard LangChain agents, keep chain_start events
1399
+ # Bridge to tool_start for tool executors
1400
+ if chain_name in {"tools", "ToolNode", "ToolExecutor"}:
1401
+ # self._vprint(f"[DASEIN][CALLBACK] Bridging chain_start to tool_start for {chain_name}") # Commented out - too noisy
1402
+ self._handle_tool_executor_start(serialized, inputs, **kwargs)
1403
+
1404
+ args_excerpt = self._excerpt(str(inputs))
1405
+
1406
+ # Record start time for duration calculation
1407
+ step_index = len(self._trace)
1408
+ start_time = datetime.now()
1409
+ self._start_times[f"chain_{step_index}"] = start_time
1410
+
1411
+ step = {
1412
+ "step_type": "chain_start",
1413
+ "tool_name": chain_name,
1414
+ "args_excerpt": args_excerpt,
1415
+ "outcome": "",
1416
+ "ts": start_time.isoformat(),
1417
+ "run_id": None,
1418
+ "parent_run_id": None,
1419
+ "step_index": step_index,
1420
+ }
1421
+ self._trace.append(step)
1422
+
1423
+ def on_chain_end(
1424
+ self,
1425
+ outputs: Dict[str, Any],
1426
+ **kwargs: Any,
1427
+ ) -> None:
1428
+ """Called when a chain ends running."""
1429
+ # 🚨 OPTIMIZED: Suppress redundant chain_end for LangGraph agents
1430
+ if self._is_langgraph:
1431
+ return
1432
+
1433
+ outcome = self._excerpt(str(outputs))
1434
+
1435
+ # Calculate duration_ms by matching with corresponding chain_start
1436
+ duration_ms = 0
1437
+ for i in range(len(self._trace) - 1, -1, -1):
1438
+ if self._trace[i].get('step_type') == 'chain_start':
1439
+ # Found the matching chain_start
1440
+ chain_key = f"chain_{i}"
1441
+ if chain_key in self._start_times:
1442
+ start_time = self._start_times[chain_key]
1443
+ end_time = datetime.now()
1444
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
1445
+ # Update the chain_start step with duration_ms
1446
+ self._trace[i]['duration_ms'] = duration_ms
1447
+ # Clean up start time
1448
+ del self._start_times[chain_key]
1449
+ break
1450
+
1451
+ step = {
1452
+ "step_type": "chain_end",
1453
+ "tool_name": "",
1454
+ "args_excerpt": "",
1455
+ "outcome": outcome,
1456
+ "ts": datetime.now().isoformat(),
1457
+ "run_id": None,
1458
+ "parent_run_id": None,
1459
+ "duration_ms": duration_ms,
1460
+ }
1461
+ self._trace.append(step)
1462
+
1463
+ def on_chain_error(
1464
+ self,
1465
+ error: BaseException,
1466
+ **kwargs: Any,
1467
+ ) -> None:
1468
+ """Called when a chain encounters an error."""
1469
+ error_msg = self._excerpt(str(error))
1470
+
1471
+ step = {
1472
+ "step_type": "chain_error",
1473
+ "tool_name": "",
1474
+ "args_excerpt": "",
1475
+ "outcome": f"ERROR: {error_msg}",
1476
+ "ts": datetime.now().isoformat(),
1477
+ "run_id": None,
1478
+ "parent_run_id": None,
1479
+ }
1480
+ self._trace.append(step)
1481
+
1482
+ def _extract_recent_message(self, inputs: Dict[str, Any]) -> str:
1483
+ """
1484
+ Extract the most recent message from LangGraph inputs to show thought progression.
1485
+
1486
+ For LangGraph agents, inputs contain {'messages': [msg1, msg2, ...]}.
1487
+ Instead of showing the entire history, we extract just the last message.
1488
+ """
1489
+ try:
1490
+ # Check if this is a LangGraph message format
1491
+ if isinstance(inputs, dict) and 'messages' in inputs:
1492
+ messages = inputs['messages']
1493
+ if isinstance(messages, list) and len(messages) > 0:
1494
+ # Get the most recent message
1495
+ last_msg = messages[-1]
1496
+
1497
+ # Extract content based on message type
1498
+ if hasattr(last_msg, 'content'):
1499
+ # LangChain message object
1500
+ content = last_msg.content
1501
+ msg_type = getattr(last_msg, 'type', 'unknown')
1502
+ return self._excerpt(f"[{msg_type}] {content}")
1503
+ elif isinstance(last_msg, tuple) and len(last_msg) >= 2:
1504
+ # Tuple format: (role, content)
1505
+ return self._excerpt(f"[{last_msg[0]}] {last_msg[1]}")
1506
+ else:
1507
+ # Unknown format, convert to string
1508
+ return self._excerpt(str(last_msg))
1509
+
1510
+ # For non-message inputs, check if it's a list of actions/tool calls
1511
+ if isinstance(inputs, list) and len(inputs) > 0:
1512
+ # This might be tool call info
1513
+ return self._excerpt(str(inputs[0]))
1514
+
1515
+ # Fall back to original behavior for non-LangGraph agents
1516
+ return self._excerpt(str(inputs))
1517
+
1518
+ except Exception as e:
1519
+ # On any error, fall back to original behavior
1520
+ return self._excerpt(str(inputs))
1521
+
1522
+ def _excerpt(self, obj: Any, max_len: int = 250, from_end: bool = False) -> str:
1523
+ """
1524
+ Truncate text to max_length with ellipsis.
1525
+
1526
+ Args:
1527
+ obj: Object to convert to string and truncate
1528
+ max_len: Maximum length of excerpt
1529
+ from_end: If True, take LAST max_len chars (better for system prompts).
1530
+ If False, take FIRST max_len chars (better for tool args).
1531
+ """
1532
+ text = str(obj)
1533
+ if len(text) <= max_len:
1534
+ return text
1535
+
1536
+ if from_end:
1537
+ # Take last X chars - better for system prompts where the end contains user's actual query
1538
+ return "..." + text[-(max_len-3):]
1539
+ else:
1540
+ # Take first X chars - better for tool inputs
1541
+ return text[:max_len-3] + "..."
1542
+
1543
+ def _extract_semantic_selectors(self, html_text: str) -> List[Dict[str, int]]:
1544
+ """
1545
+ Extract semantic HTML tags from output for grounding web browse rules.
1546
+ Only extracts semantic tags (nav, header, h1, etc.) to keep trace lightweight.
1547
+
1548
+ Args:
1549
+ html_text: Output text that may contain HTML
1550
+
1551
+ Returns:
1552
+ List of {"tag": str, "count": int} sorted by count descending, or None if no HTML
1553
+ """
1554
+ import re
1555
+
1556
+ # Quick check: does this look like HTML?
1557
+ if '<' not in html_text or '>' not in html_text:
1558
+ return None
1559
+
1560
+ # Semantic tags we care about (prioritized for web browse agents)
1561
+ semantic_tags = [
1562
+ # Navigation/Structure (highest priority)
1563
+ 'nav', 'header', 'footer', 'main', 'article', 'section', 'aside',
1564
+
1565
+ # Headers (critical for "find headers" queries!)
1566
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
1567
+
1568
+ # Interactive
1569
+ 'a', 'button', 'form', 'input', 'textarea', 'select', 'label',
1570
+
1571
+ # Lists (often used for navigation)
1572
+ 'ul', 'ol', 'li',
1573
+
1574
+ # Tables (data extraction)
1575
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
1576
+
1577
+ # Media
1578
+ 'img', 'video', 'audio'
1579
+ ]
1580
+
1581
+ # Count occurrences of each semantic tag
1582
+ found_tags = {}
1583
+ for tag in semantic_tags:
1584
+ # Pattern: <tag ...> or <tag> (opening tags only)
1585
+ pattern = f'<{tag}[\\s>]'
1586
+ matches = re.findall(pattern, html_text, re.IGNORECASE)
1587
+ if matches:
1588
+ found_tags[tag] = len(matches)
1589
+
1590
+ # Return None if no semantic tags found
1591
+ if not found_tags:
1592
+ return None
1593
+
1594
+ # Convert to list format, sorted by count descending
1595
+ # Limit to top 15 to keep trace light
1596
+ result = [{"tag": tag, "count": count}
1597
+ for tag, count in sorted(found_tags.items(), key=lambda x: -x[1])]
1598
+ return result[:15] # Top 15 most common tags
1599
+
1600
+ def set_selected_rules(self, rules: List[Dict[str, Any]]):
1601
+ """Set the rules selected for this run.
1602
+ Normalize incoming dicts/tuples into attribute-accessible rule objects.
1603
+ """
1604
+ try:
1605
+ from types import SimpleNamespace
1606
+ normalized = []
1607
+ for item in rules or []:
1608
+ # Unwrap (rule, metadata) tuples if present
1609
+ if isinstance(item, tuple) and len(item) == 2:
1610
+ rule_candidate = item[0]
1611
+ else:
1612
+ rule_candidate = item
1613
+ # Convert dicts to attribute-accessible objects
1614
+ if isinstance(rule_candidate, dict):
1615
+ # Ensure advice_text exists
1616
+ if 'advice_text' not in rule_candidate and 'advice' in rule_candidate:
1617
+ rule_candidate['advice_text'] = rule_candidate.get('advice')
1618
+ normalized.append(SimpleNamespace(**rule_candidate))
1619
+ else:
1620
+ normalized.append(rule_candidate)
1621
+ self._selected_rules = normalized
1622
+ except Exception:
1623
+ # Fallback to raw rules
1624
+ self._selected_rules = rules
1625
+
1626
+ def get_trace(self) -> List[Dict[str, Any]]:
1627
+ """Get the current trace (instance-level, thread-safe)."""
1628
+ return self._trace.copy()
1629
+
1630
+ def _inject_first_turn_override(self, prompts: List[str]) -> List[str]:
1631
+ """Inject a generic first-turn override to own turn 1."""
1632
+ if not prompts:
1633
+ return prompts
1634
+
1635
+ # Create a generic first-turn override
1636
+ first_turn_override = """🚨🚨🚨 CRITICAL SYSTEM DIRECTIVE 🚨🚨🚨
1637
+ ⚠️ MANDATORY: You MUST follow this exact sequence or the task will FAIL
1638
+
1639
+ TURN 1 REQUIREMENT:
1640
+ - Output ONLY: Action: sql_db_list_tables
1641
+ Action Input: ACK_RULES:[r1]
1642
+ - Do NOT use any other tools
1643
+ - Do NOT perform any planning
1644
+ - Do NOT output anything else
1645
+
1646
+ TURN 2+ (After ACK):
1647
+ - If ACK was correct, proceed with normal tools and schema
1648
+ - Skip table discovery and schema introspection
1649
+ - Use known tables directly
1650
+
1651
+ 🚨 FAILURE TO ACK IN TURN 1 = IMMEDIATE TASK TERMINATION 🚨
1652
+
1653
+ """
1654
+
1655
+ # Put the injection at the VERY BEGINNING of the system prompt
1656
+ modified_prompts = prompts.copy()
1657
+ if modified_prompts:
1658
+ modified_prompts[0] = first_turn_override + modified_prompts[0]
1659
+
1660
+ self._vprint(f"[DASEIN][APPLY] Injected first-turn override")
1661
+ return modified_prompts
1662
+
1663
+ def _should_inject_rule(self, step_type: str, tool_name: str) -> bool:
1664
+ """Determine if we should inject a rule at this step."""
1665
+ # Inject for LLM starts (system-level rules) and tool starts (tool-level rules)
1666
+ if step_type == "llm_start":
1667
+ return True
1668
+ if step_type == "tool_start":
1669
+ return True
1670
+ return False
1671
+
1672
+ def _inject_rule_if_applicable(self, step_type: str, tool_name: str, prompts: List[str]) -> List[str]:
1673
+ """Inject rules into prompts if applicable."""
1674
+
1675
+ if not self._should_inject_rule(step_type, tool_name):
1676
+ return prompts
1677
+
1678
+ # If no rules selected yet, return prompts unchanged
1679
+ if not self._selected_rules:
1680
+ return prompts
1681
+
1682
+ # Check guard to prevent duplicate injection
1683
+ # 🎯 CRITICAL: For LangGraph planning nodes, SKIP the guard - we need to inject on EVERY call
1684
+ # because the same node (e.g., supervisor) can be called multiple times dynamically
1685
+ use_guard = True
1686
+ if hasattr(self, '_is_langgraph') and self._is_langgraph:
1687
+ if step_type == 'llm_start' and hasattr(self, '_current_chain_node'):
1688
+ # For planning nodes, skip guard to allow re-injection on subsequent calls
1689
+ if hasattr(self, '_planning_nodes') and self._current_chain_node in self._planning_nodes:
1690
+ use_guard = False
1691
+
1692
+ if use_guard:
1693
+ guard_key = (step_type, tool_name)
1694
+ if guard_key in self._injection_guard:
1695
+ return prompts
1696
+
1697
+ try:
1698
+ # Inject rules that target llm_start and tool_start (both go to system prompt)
1699
+ system_rules = []
1700
+ for rule_meta in self._selected_rules:
1701
+ # Handle tuple format from select_rules: (rule, metadata)
1702
+ if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
1703
+ rule, metadata = rule_meta
1704
+ elif isinstance(rule_meta, dict):
1705
+ if 'rule' in rule_meta:
1706
+ rule = rule_meta.get('rule', {})
1707
+ else:
1708
+ rule = rule_meta
1709
+ else:
1710
+ rule = rule_meta
1711
+
1712
+ # Check if this rule targets system-level injection (llm_start only)
1713
+ target_step_type = getattr(rule, 'target_step_type', '')
1714
+
1715
+ # 🚨 CRITICAL: For LangGraph agents, only skip planning rules if agent was successfully recreated
1716
+ # If recreation failed, we need to inject via callback as fallback
1717
+ if step_type == 'llm_start' and hasattr(self, '_is_langgraph') and self._is_langgraph:
1718
+ # Only skip if agent was actually recreated with planning rules embedded
1719
+ if hasattr(self, '_agent_was_recreated') and self._agent_was_recreated:
1720
+ if target_step_type in ['llm_start', 'chain_start']:
1721
+ self._vprint(f"[DASEIN][CALLBACK] Skipping planning rule {getattr(rule, 'id', 'unknown')} for LangGraph agent (already injected at creation)")
1722
+ continue
1723
+
1724
+ # 🎯 NODE-SCOPED INJECTION: Check target_node if specified (for node-specific rules)
1725
+ if target_step_type in ['llm_start', 'chain_start']:
1726
+ current_node = getattr(self, '_current_chain_node', None)
1727
+
1728
+ # Check if this rule targets a specific node
1729
+ target_node = getattr(rule, 'target_node', None)
1730
+ if target_node:
1731
+ # Rule has explicit target_node - ONLY inject if we're in that node
1732
+ if current_node != target_node:
1733
+ # Silently skip - not the target node
1734
+ continue
1735
+ else:
1736
+ # No target_node specified - use existing planning_nodes logic (backward compatibility)
1737
+ if hasattr(self, '_planning_nodes') and self._planning_nodes:
1738
+ # Check if current node is in the planning nodes set
1739
+ if current_node not in self._planning_nodes:
1740
+ # Silently skip non-planning nodes
1741
+ continue
1742
+ # Injecting into planning node (logged in detailed injection log below)
1743
+
1744
+ advice = getattr(rule, 'advice_text', getattr(rule, 'advice', ''))
1745
+ if advice:
1746
+ system_rules.append(advice)
1747
+
1748
+ # Apply system-level rules if any
1749
+ if system_rules and prompts:
1750
+ modified_prompts = prompts.copy()
1751
+ system_prompt = modified_prompts[0]
1752
+
1753
+ # Combine all system rules with much stronger language
1754
+ rule_injections = []
1755
+ for advice in system_rules:
1756
+ if "TOOL RULE:" in advice:
1757
+ # Make tool rules even more explicit
1758
+ rule_injections.append(f"🚨 CRITICAL TOOL OVERRIDE: {advice}")
1759
+ else:
1760
+ rule_injections.append(f"🚨 CRITICAL SYSTEM OVERRIDE: {advice}")
1761
+
1762
+ # Build execution state context (agent-agnostic, with argument previews)
1763
+ # Strategy: Show all if ≤5 calls, else show most recent 3
1764
+ # Rationale: Small counts get full context; larger counts show recent to prevent duplicates
1765
+ state_context = ""
1766
+ if hasattr(self, '_function_calls_made') and self._function_calls_made:
1767
+ state_lines = []
1768
+ for func_name in sorted(self._function_calls_made.keys()):
1769
+ calls = self._function_calls_made[func_name]
1770
+ count = len(calls)
1771
+
1772
+ # Hybrid window: show all if ≤5 calls, else show recent 3
1773
+ if count <= 5:
1774
+ # Show all calls with previews
1775
+ state_lines.append(f" • {func_name}: called {count}x:")
1776
+ for call in calls:
1777
+ preview = call.get('preview', '')
1778
+ if preview:
1779
+ state_lines.append(f" [step {call['step']}] {preview}")
1780
+ else:
1781
+ state_lines.append(f" [step {call['step']}] (no args)")
1782
+ else:
1783
+ # Show summary + recent 3 with previews
1784
+ state_lines.append(f" • {func_name}: called {count}x (most recent 3):")
1785
+ for call in calls[-3:]:
1786
+ preview = call.get('preview', '')
1787
+ if preview:
1788
+ state_lines.append(f" [step {call['step']}] {preview}")
1789
+ else:
1790
+ state_lines.append(f" [step {call['step']}] (no args)")
1791
+
1792
+ if state_lines:
1793
+ state_context = f"""
1794
+ EXECUTION STATE (functions called so far in this run):
1795
+ {chr(10).join(state_lines)}
1796
+
1797
+ """
1798
+
1799
+ combined_injection = f""" SYSTEM OVERRIDE — PLANNING TURN ONLY
1800
+ These rules OVERRIDE all defaults. You MUST enforce them exactly or the task FAILS.
1801
+
1802
+ Tags: AVOID (absolute ban), SKIP (force bypass), FIX (mandatory params), PREFER (ranked choice), HINT (optional).
1803
+ Precedence: AVOID/SKIP > FIX > PREFER > HINT. On conflict, the higher rule ALWAYS wins.
1804
+
1805
+ {state_context}Checklist (non-negotiable):
1806
+ - AVOID: no banned targets under ANY condition.
1807
+ - SKIP: bypass skipped steps/tools; NEVER retry them.
1808
+ - FIX: all required params/settings MUST be included.
1809
+ - PREFER: when multiple compliant options exist, choose the preferred—NO exceptions.
1810
+ - Recovery: if a banned/skipped item already failed, IMMEDIATELY switch to a compliant alternative.
1811
+
1812
+ Output Contract: Produce ONE compliant tool/function call (or direct answer if none is needed).
1813
+ NO reasoning, NO justification, NO markdown.
1814
+
1815
+ Rules to Enforce:
1816
+
1817
+
1818
+ {chr(10).join(rule_injections)}
1819
+
1820
+
1821
+ """
1822
+ # Put the injection at the VERY BEGINNING of the system prompt
1823
+ modified_prompts[0] = combined_injection + system_prompt
1824
+
1825
+ # Add to guard (only if we're using the guard)
1826
+ if use_guard:
1827
+ self._injection_guard.add(guard_key)
1828
+
1829
+ # Log the complete injection for debugging
1830
+ # Compact injection summary
1831
+ if hasattr(self, '_is_langgraph') and self._is_langgraph:
1832
+ # LangGraph: show node name
1833
+ func_count = len(self._function_calls_made) if hasattr(self, '_function_calls_made') and state_context else 0
1834
+ node_name = getattr(self, '_current_chain_node', 'unknown')
1835
+ print(f"[DASEIN] 🎯 Injecting {len(system_rules)} rule(s) into {node_name} | State: {func_count} functions tracked")
1836
+ else:
1837
+ # LangChain: simpler logging without node name
1838
+ print(f"[DASEIN] 🎯 Injecting {len(system_rules)} rule(s) into agent")
1839
+
1840
+ return modified_prompts
1841
+
1842
+ except Exception as e:
1843
+ self._vprint(f"[DASEIN][APPLY] Injection failed: {e}")
1844
+
1845
+ return prompts
1846
+
1847
+ def _inject_tool_rule_if_applicable(self, step_type: str, tool_name: str, input_str: str) -> str:
1848
+ """Inject rules into tool input if applicable."""
1849
+ if not self._should_inject_rule(step_type, tool_name):
1850
+ return input_str
1851
+
1852
+ # If no rules selected yet, return input unchanged
1853
+ if not self._selected_rules:
1854
+ return input_str
1855
+
1856
+ # Check guard to prevent duplicate injection
1857
+ guard_key = (step_type, tool_name)
1858
+ if guard_key in self._injection_guard:
1859
+ return input_str
1860
+
1861
+ try:
1862
+ # Inject rules that target tool_start
1863
+ tool_rules = []
1864
+ current_node = getattr(self, '_current_chain_node', None)
1865
+
1866
+ for rule_meta in self._selected_rules:
1867
+ # Handle tuple format from select_rules: (rule, metadata)
1868
+ if isinstance(rule_meta, tuple) and len(rule_meta) == 2:
1869
+ rule, metadata = rule_meta
1870
+ else:
1871
+ rule = rule_meta
1872
+ metadata = {}
1873
+
1874
+ # Only apply rules that target tool_start
1875
+ if rule.target_step_type == "tool_start":
1876
+ # 🎯 NODE-SCOPED INJECTION: Check target_node if specified
1877
+ target_node = getattr(rule, 'target_node', None)
1878
+ if target_node:
1879
+ # Rule has explicit target_node - ONLY inject if we're in that node
1880
+ if current_node != target_node:
1881
+ # Silently skip - not the target node
1882
+ continue
1883
+ # No target_node specified - inject into any node using this tool (backward compat)
1884
+
1885
+ tool_rules.append(rule)
1886
+ self._vprint(f"[DASEIN][APPLY] Tool rule: {rule.advice_text[:100]}...")
1887
+
1888
+ if tool_rules:
1889
+ # Apply tool-level rule injection
1890
+ modified_input = self._apply_tool_rules(input_str, tool_rules)
1891
+ self._injection_guard.add(guard_key)
1892
+ return modified_input
1893
+ else:
1894
+ return input_str
1895
+
1896
+ except Exception as e:
1897
+ self._vprint(f"[DASEIN][APPLY] Error injecting tool rules: {e}")
1898
+ return input_str
1899
+
1900
+ def _apply_tool_rules(self, input_str: str, rules: List) -> str:
1901
+ """Apply tool-level rules to modify the input string."""
1902
+ modified_input = input_str
1903
+
1904
+ for rule in rules:
1905
+ try:
1906
+ # Apply the rule's advice to modify the tool input
1907
+ if "strip" in rule.advice_text.lower() and "fence" in rule.advice_text.lower():
1908
+ # Strip markdown code fences
1909
+ import re
1910
+ # Remove ```sql...``` or ```...``` patterns
1911
+ modified_input = re.sub(r'```(?:sql)?\s*(.*?)\s*```', r'\1', modified_input, flags=re.DOTALL)
1912
+ self._vprint(f"[DASEIN][APPLY] Stripped code fences from tool input")
1913
+ elif "strip" in rule.advice_text.lower() and "whitespace" in rule.advice_text.lower():
1914
+ # Strip leading/trailing whitespace
1915
+ modified_input = modified_input.strip()
1916
+ self._vprint(f"[DASEIN][APPLY] Stripped whitespace from tool input")
1917
+ # Add more rule types as needed
1918
+
1919
+ except Exception as e:
1920
+ self._vprint(f"[DASEIN][APPLY] Error applying tool rule: {e}")
1921
+ continue
1922
+
1923
+ return modified_input
1924
+
1925
+ def _handle_tool_executor_start(
1926
+ self,
1927
+ serialized: Dict[str, Any],
1928
+ inputs: Dict[str, Any],
1929
+ **kwargs: Any,
1930
+ ) -> None:
1931
+ """Handle tool executor start - bridge from chain_start to tool_start."""
1932
+ self._vprint(f"[DASEIN][CALLBACK] tool_start (from chain_start)")
1933
+
1934
+ # Extract tool information from inputs
1935
+ tool_name = "unknown"
1936
+ tool_input = ""
1937
+
1938
+ if isinstance(inputs, dict):
1939
+ if "tool" in inputs:
1940
+ tool_name = inputs["tool"]
1941
+ elif "tool_name" in inputs:
1942
+ tool_name = inputs["tool_name"]
1943
+
1944
+ if "tool_input" in inputs:
1945
+ tool_input = str(inputs["tool_input"])
1946
+ elif "input" in inputs:
1947
+ tool_input = str(inputs["input"])
1948
+ else:
1949
+ tool_input = str(inputs)
1950
+ else:
1951
+ tool_input = str(inputs)
1952
+
1953
+ self._vprint(f"[DASEIN][CALLBACK] Tool: {tool_name}")
1954
+ self._vprint(f"[DASEIN][CALLBACK] Input: {tool_input[:100]}...")
1955
+
1956
+ # Check if we have tool_start rules that cover this tool
1957
+ tool_rules = [rule for rule in self._selected_rules if rule.target_step_type == "tool_start"]
1958
+ covered_rules = [rule for rule in tool_rules if self._rule_covers_tool(rule, tool_name, tool_input)]
1959
+
1960
+ if covered_rules:
1961
+ self._vprint(f"[DASEIN][APPLY] tool_start: {len(covered_rules)} rules cover this tool call")
1962
+ # Fire micro-turn for rule application
1963
+ modified_input = self._fire_micro_turn_for_tool_rules(covered_rules, tool_name, tool_input)
1964
+ else:
1965
+ self._vprint(f"[DASEIN][APPLY] tool_start: no rules cover this tool call")
1966
+ modified_input = tool_input
1967
+
1968
+ args_excerpt = self._excerpt(modified_input)
1969
+
1970
+ step = {
1971
+ "step_type": "tool_start",
1972
+ "tool_name": tool_name,
1973
+ "args_excerpt": args_excerpt,
1974
+ "outcome": "",
1975
+ "ts": datetime.now().isoformat(),
1976
+ "run_id": kwargs.get("run_id"),
1977
+ "parent_run_id": kwargs.get("parent_run_id"),
1978
+ }
1979
+ self._trace.append(step)
1980
+
1981
+ def _rule_covers_tool(self, rule, tool_name: str, tool_input: str) -> bool:
1982
+ """Check if a rule covers the given tool call."""
1983
+ try:
1984
+ # Check if rule references this tool
1985
+ if hasattr(rule, 'references') and rule.references:
1986
+ if hasattr(rule.references, 'tools') and rule.references.tools:
1987
+ if tool_name not in rule.references.tools:
1988
+ return False
1989
+
1990
+ # Check trigger patterns if they exist
1991
+ if hasattr(rule, 'trigger_pattern') and rule.trigger_pattern:
1992
+ # For now, assume all tool_start rules cover their referenced tools
1993
+ # This can be made more sophisticated later
1994
+ pass
1995
+
1996
+ return True
1997
+ except Exception as e:
1998
+ self._vprint(f"[DASEIN][COVERAGE] Error checking rule coverage: {e}")
1999
+ return False
2000
+
2001
+ def _fire_micro_turn_for_tool_rules(self, rules, tool_name: str, tool_input: str) -> str:
2002
+ """Fire a micro-turn LLM call to apply tool rules."""
2003
+ try:
2004
+ # Use the first rule for now (can be extended to handle multiple rules)
2005
+ rule = rules[0]
2006
+ rule_id = getattr(rule, 'id', 'unknown')
2007
+
2008
+ self._vprint(f"[DASEIN][MICROTURN] rule_id={rule_id} tool={tool_name}")
2009
+
2010
+ # Create micro-turn prompt
2011
+ micro_turn_prompt = self._create_micro_turn_prompt(rule, tool_name, tool_input)
2012
+
2013
+ # Fire actual micro-turn LLM call
2014
+ modified_input = self._execute_micro_turn_llm_call(micro_turn_prompt, tool_input)
2015
+
2016
+ # Store the modified input for retrieval during tool execution
2017
+ input_key = f"{tool_name}:{hash(tool_input)}"
2018
+ _MODIFIED_TOOL_INPUTS[input_key] = modified_input
2019
+
2020
+ self._vprint(f"[DASEIN][MICROTURN] Applied rule {rule_id}: {str(tool_input)[:50]}... -> {str(modified_input)[:50]}...")
2021
+
2022
+ return modified_input
2023
+
2024
+ except Exception as e:
2025
+ self._vprint(f"[DASEIN][MICROTURN] Error in micro-turn: {e}")
2026
+ return tool_input
2027
+
2028
+ def _create_micro_turn_prompt(self, rule, tool_name: str, tool_input: str) -> str:
2029
+ """Create the micro-turn prompt for rule application."""
2030
+ advice = getattr(rule, 'advice', '')
2031
+ return f"""Apply this rule to the tool input:
2032
+
2033
+ Rule: {advice}
2034
+ Tool: {tool_name}
2035
+ Current Input: {tool_input}
2036
+
2037
+ Output only the corrected tool input:"""
2038
+
2039
+ def _execute_micro_turn_llm_call(self, prompt: str, original_input: str) -> str:
2040
+ """Execute the actual micro-turn LLM call."""
2041
+ try:
2042
+ if not self._llm:
2043
+ self._vprint(f"[DASEIN][MICROTURN] No LLM available for micro-turn call")
2044
+ return original_input
2045
+
2046
+ self._vprint(f"[DASEIN][MICROTURN] Executing micro-turn LLM call")
2047
+ self._vprint(f"[DASEIN][MICROTURN] Prompt: {prompt[:200]}...")
2048
+
2049
+ # Make the micro-turn LLM call
2050
+ # Create a simple message list for the LLM
2051
+ messages = [{"role": "user", "content": prompt}]
2052
+
2053
+ # Call the LLM
2054
+ response = self._llm.invoke(messages)
2055
+
2056
+ # Extract the response content
2057
+ if hasattr(response, 'content'):
2058
+ modified_input = response.content.strip()
2059
+ elif isinstance(response, str):
2060
+ modified_input = response.strip()
2061
+ else:
2062
+ modified_input = str(response).strip()
2063
+
2064
+ self._vprint(f"[DASEIN][MICROTURN] LLM response: {modified_input[:100]}...")
2065
+
2066
+ # 🚨 CRITICAL: Parse JSON responses with markdown fences
2067
+ if modified_input.startswith('```json') or modified_input.startswith('```'):
2068
+ try:
2069
+ # Extract JSON from markdown fences
2070
+ import re
2071
+ import json
2072
+ json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', modified_input, re.DOTALL)
2073
+ if json_match:
2074
+ json_str = json_match.group(1)
2075
+ parsed_json = json.loads(json_str)
2076
+ # Convert back to the expected format
2077
+ if isinstance(parsed_json, dict) and 'name' in parsed_json and 'args' in parsed_json:
2078
+ modified_input = parsed_json
2079
+ self._vprint(f"[DASEIN][MICROTURN] Parsed JSON from markdown fences: {parsed_json}")
2080
+ else:
2081
+ self._vprint(f"[DASEIN][MICROTURN] JSON doesn't have expected structure, using as-is")
2082
+ else:
2083
+ self._vprint(f"[DASEIN][MICROTURN] Could not extract JSON from markdown fences")
2084
+ except Exception as e:
2085
+ self._vprint(f"[DASEIN][MICROTURN] Error parsing JSON: {e}")
2086
+
2087
+ # Validate the response - only fallback if completely empty
2088
+ if not modified_input:
2089
+ self._vprint(f"[DASEIN][MICROTURN] LLM response empty, using original input")
2090
+ return original_input
2091
+
2092
+ return modified_input
2093
+
2094
+ except Exception as e:
2095
+ self._vprint(f"[DASEIN][MICROTURN] Error executing micro-turn LLM call: {e}")
2096
+ return original_input
2097
+
2098
+
2099
+ def get_trace() -> List[Dict[str, Any]]:
2100
+ """
2101
+ DEPRECATED: Legacy function for backward compatibility.
2102
+ Get the current trace from active CognateProxy instances.
2103
+
2104
+ Returns:
2105
+ List of trace step dictionaries (empty if no active traces)
2106
+ """
2107
+ # Try to get trace from active CognateProxy instances
2108
+ try:
2109
+ import gc
2110
+ for obj in gc.get_objects():
2111
+ if hasattr(obj, '_last_run_trace') and obj._last_run_trace:
2112
+ return obj._last_run_trace.copy()
2113
+ if hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, '_trace'):
2114
+ return obj._callback_handler._trace.copy()
2115
+ except Exception:
2116
+ pass
2117
+
2118
+ return [] # Return empty list if no trace found
2119
+
2120
+
2121
+ def get_modified_tool_input(tool_name: str, original_input: str) -> str:
2122
+ """
2123
+ Get the modified tool input if it exists.
2124
+
2125
+ Args:
2126
+ tool_name: Name of the tool
2127
+ original_input: Original tool input
2128
+
2129
+ Returns:
2130
+ Modified tool input if available, otherwise original input
2131
+ """
2132
+ input_key = f"{tool_name}:{hash(original_input)}"
2133
+ return _MODIFIED_TOOL_INPUTS.get(input_key, original_input)
2134
+
2135
+
2136
+ def clear_modified_tool_inputs():
2137
+ """Clear all modified tool inputs."""
2138
+ global _MODIFIED_TOOL_INPUTS
2139
+ _MODIFIED_TOOL_INPUTS.clear()
2140
+
2141
+
2142
+ def clear_trace() -> None:
2143
+ """
2144
+ DEPRECATED: Legacy function for backward compatibility.
2145
+ Clear traces in active CognateProxy instances.
2146
+ """
2147
+ # Try to clear traces in active CognateProxy instances
2148
+ try:
2149
+ import gc
2150
+ for obj in gc.get_objects():
2151
+ if hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, 'reset_run_state'):
2152
+ obj._callback_handler.reset_run_state()
2153
+ except Exception:
2154
+ pass # Ignore if not available
2155
+
2156
+
2157
+ def print_trace(max_chars: int = 240, only: tuple[str, ...] | None = None, suppress: tuple[str, ...] = ("chain_end",), show_tree: bool = True, show_summary: bool = True) -> None:
2158
+ """
2159
+ Print a compact fixed-width table of the trace with tree-like view and filtering.
2160
+
2161
+ Args:
2162
+ max_chars: Maximum characters per line (default 240)
2163
+ only: Filter by step_type if provided (e.g., ("llm_start", "llm_end"))
2164
+ suppress: Suppress any step_type in this tuple (default: ("chain_end",))
2165
+ show_tree: If True, left-pad args_excerpt by 2*depth spaces for tree-like view
2166
+ show_summary: If True, show step_type counts and deduped rows summary
2167
+ """
2168
+ # Try to get trace from active CognateProxy instances
2169
+ trace = None
2170
+ try:
2171
+ # Import here to avoid circular imports
2172
+ from dasein.api import _global_cognate_proxy
2173
+ if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_wrapped_llm') and _global_cognate_proxy._wrapped_llm:
2174
+ trace = _global_cognate_proxy._wrapped_llm.get_trace()
2175
+ except:
2176
+ pass
2177
+
2178
+ if not trace:
2179
+ trace = get_trace() # Use the updated get_trace() function
2180
+
2181
+ # If global trace is empty, try to get it from the last completed run
2182
+ if not trace:
2183
+ # Try to get trace from any active CognateProxy instances
2184
+ try:
2185
+ import gc
2186
+ for obj in gc.get_objects():
2187
+ # Look for CognateProxy instances with captured traces
2188
+ if hasattr(obj, '_last_run_trace') and obj._last_run_trace:
2189
+ trace = obj._last_run_trace
2190
+ print(f"[DASEIN][TRACE] Retrieved trace from CognateProxy: {len(trace)} steps")
2191
+ break
2192
+ # Fallback: try callback handler
2193
+ elif hasattr(obj, '_callback_handler') and hasattr(obj._callback_handler, 'get_trace'):
2194
+ potential_trace = obj._callback_handler.get_trace()
2195
+ if potential_trace:
2196
+ trace = potential_trace
2197
+ print(f"[DASEIN][TRACE] Retrieved trace from callback handler: {len(trace)} steps")
2198
+ break
2199
+ except Exception as e:
2200
+ pass
2201
+
2202
+ if not trace:
2203
+ print("No trace data available.")
2204
+ return
2205
+
2206
+ # Print execution state if available
2207
+ try:
2208
+ from dasein.api import _global_cognate_proxy
2209
+ if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_callback_handler'):
2210
+ handler = _global_cognate_proxy._callback_handler
2211
+ if hasattr(handler, '_function_calls_made') and handler._function_calls_made:
2212
+ print("\n" + "=" * 80)
2213
+ print("EXECUTION STATE (Functions Called During Run):")
2214
+ print("=" * 80)
2215
+ for func_name in sorted(handler._function_calls_made.keys()):
2216
+ calls = handler._function_calls_made[func_name]
2217
+ count = len(calls)
2218
+ print(f" • {func_name}: called {count}x")
2219
+ # Hybrid window: show all if ≤5, else show most recent 3 (matches injection logic)
2220
+ if count <= 5:
2221
+ # Show all calls
2222
+ for call in calls:
2223
+ preview = call.get('preview', '(no preview)')
2224
+ if len(preview) > 80:
2225
+ preview = preview[:80] + '...'
2226
+ print(f" [step {call['step']}] {preview}")
2227
+ else:
2228
+ # Show recent 3
2229
+ print(f" ... (showing most recent 3 of {count}):")
2230
+ for call in calls[-3:]:
2231
+ preview = call.get('preview', '(no preview)')
2232
+ if len(preview) > 80:
2233
+ preview = preview[:80] + '...'
2234
+ print(f" [step {call['step']}] {preview}")
2235
+ print("=" * 80 + "\n")
2236
+ except Exception as e:
2237
+ pass # Silently skip if state not available
2238
+
2239
+ # Filter by step_type if only is provided
2240
+ filtered_trace = trace
2241
+ if only:
2242
+ filtered_trace = [step for step in trace if step.get("step_type") in only]
2243
+
2244
+ # Suppress any step_type in suppress tuple
2245
+ if suppress:
2246
+ filtered_trace = [step for step in filtered_trace if step.get("step_type") not in suppress]
2247
+
2248
+ if not filtered_trace:
2249
+ print("No trace data matching filter criteria.")
2250
+ return
2251
+
2252
+ # Build depth map from parent_run_id
2253
+ depth_map = {}
2254
+ for step in filtered_trace:
2255
+ run_id = step.get("run_id")
2256
+ parent_run_id = step.get("parent_run_id")
2257
+
2258
+ if run_id is None or parent_run_id is None or parent_run_id not in depth_map:
2259
+ depth_map[run_id] = 0
2260
+ else:
2261
+ depth_map[run_id] = depth_map[parent_run_id] + 1
2262
+
2263
+ # Calculate column widths based on max_chars
2264
+ # Reserve space for: # (3), step_type (15), tool_name (25), separators (6)
2265
+ available_width = max_chars - 3 - 15 - 25 - 6
2266
+ excerpt_width = available_width // 2
2267
+ outcome_width = available_width - excerpt_width
2268
+
2269
+ # Print header
2270
+ print(f"{'#':<3} {'step_type':<15} {'tool_name':<25} {'args_excerpt':<{excerpt_width}} {'outcome':<{outcome_width}}")
2271
+ print("-" * max_chars)
2272
+
2273
+ # Print each step
2274
+ for i, step in enumerate(filtered_trace, 1):
2275
+ step_type = step.get("step_type", "")[:15]
2276
+ tool_name = str(step.get("tool_name", ""))[:25]
2277
+ args_excerpt = step.get("args_excerpt", "")
2278
+ outcome = step.get("outcome", "")
2279
+
2280
+ # Apply tree indentation if show_tree is True
2281
+ if show_tree:
2282
+ run_id = step.get("run_id")
2283
+ depth = depth_map.get(run_id, 0)
2284
+ args_excerpt = " " * depth + args_excerpt
2285
+
2286
+ # Truncate to fit column widths
2287
+ args_excerpt = args_excerpt[:excerpt_width]
2288
+ outcome = outcome[:outcome_width]
2289
+
2290
+ print(f"{i:<3} {step_type:<15} {tool_name:<25} {args_excerpt:<{excerpt_width}} {outcome:<{outcome_width}}")
2291
+
2292
+ # Show summary if requested
2293
+ if show_summary:
2294
+ print("\n" + "=" * max_chars)
2295
+
2296
+ # Count steps by step_type
2297
+ step_counts = {}
2298
+ for step in filtered_trace:
2299
+ step_type = step.get("step_type", "unknown")
2300
+ step_counts[step_type] = step_counts.get(step_type, 0) + 1
2301
+
2302
+ print("Step counts:")
2303
+ for step_type, count in sorted(step_counts.items()):
2304
+ print(f" {step_type}: {count}")
2305
+
2306
+ # Add compact function call summary
2307
+ try:
2308
+ from dasein.api import _global_cognate_proxy
2309
+ if _global_cognate_proxy and hasattr(_global_cognate_proxy, '_callback_handler'):
2310
+ handler = _global_cognate_proxy._callback_handler
2311
+ if hasattr(handler, '_function_calls_made') and handler._function_calls_made:
2312
+ print("\nFunction calls:")
2313
+ for func_name in sorted(handler._function_calls_made.keys()):
2314
+ count = len(handler._function_calls_made[func_name])
2315
+ print(f" {func_name}: {count}")
2316
+ except Exception:
2317
+ pass
2318
+
2319
+ # Count deduped rows skipped (steps that were filtered out)
2320
+ total_steps = len(trace)
2321
+ shown_steps = len(filtered_trace)
2322
+ skipped_steps = total_steps - shown_steps
2323
+
2324
+ if skipped_steps > 0:
2325
+ print(f"Deduped rows skipped: {skipped_steps}")