chuk-ai-session-manager 0.7.1__py3-none-any.whl → 0.8__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.
- chuk_ai_session_manager/__init__.py +84 -40
- chuk_ai_session_manager/api/__init__.py +1 -1
- chuk_ai_session_manager/api/simple_api.py +53 -59
- chuk_ai_session_manager/exceptions.py +31 -17
- chuk_ai_session_manager/guards/__init__.py +118 -0
- chuk_ai_session_manager/guards/bindings.py +217 -0
- chuk_ai_session_manager/guards/cache.py +163 -0
- chuk_ai_session_manager/guards/manager.py +819 -0
- chuk_ai_session_manager/guards/models.py +498 -0
- chuk_ai_session_manager/guards/ungrounded.py +159 -0
- chuk_ai_session_manager/infinite_conversation.py +86 -79
- chuk_ai_session_manager/memory/__init__.py +247 -0
- chuk_ai_session_manager/memory/artifacts_bridge.py +469 -0
- chuk_ai_session_manager/memory/context_packer.py +347 -0
- chuk_ai_session_manager/memory/fault_handler.py +507 -0
- chuk_ai_session_manager/memory/manifest.py +307 -0
- chuk_ai_session_manager/memory/models.py +1084 -0
- chuk_ai_session_manager/memory/mutation_log.py +186 -0
- chuk_ai_session_manager/memory/pack_cache.py +206 -0
- chuk_ai_session_manager/memory/page_table.py +275 -0
- chuk_ai_session_manager/memory/prefetcher.py +192 -0
- chuk_ai_session_manager/memory/tlb.py +247 -0
- chuk_ai_session_manager/memory/vm_prompts.py +238 -0
- chuk_ai_session_manager/memory/working_set.py +574 -0
- chuk_ai_session_manager/models/__init__.py +21 -9
- chuk_ai_session_manager/models/event_source.py +3 -1
- chuk_ai_session_manager/models/event_type.py +10 -1
- chuk_ai_session_manager/models/session.py +103 -68
- chuk_ai_session_manager/models/session_event.py +69 -68
- chuk_ai_session_manager/models/session_metadata.py +9 -10
- chuk_ai_session_manager/models/session_run.py +21 -22
- chuk_ai_session_manager/models/token_usage.py +76 -76
- chuk_ai_session_manager/procedural_memory/__init__.py +70 -0
- chuk_ai_session_manager/procedural_memory/formatter.py +407 -0
- chuk_ai_session_manager/procedural_memory/manager.py +523 -0
- chuk_ai_session_manager/procedural_memory/models.py +371 -0
- chuk_ai_session_manager/sample_tools.py +79 -46
- chuk_ai_session_manager/session_aware_tool_processor.py +27 -16
- chuk_ai_session_manager/session_manager.py +238 -197
- chuk_ai_session_manager/session_prompt_builder.py +163 -111
- chuk_ai_session_manager/session_storage.py +45 -52
- {chuk_ai_session_manager-0.7.1.dist-info → chuk_ai_session_manager-0.8.dist-info}/METADATA +79 -3
- chuk_ai_session_manager-0.8.dist-info/RECORD +45 -0
- {chuk_ai_session_manager-0.7.1.dist-info → chuk_ai_session_manager-0.8.dist-info}/WHEEL +1 -1
- chuk_ai_session_manager-0.7.1.dist-info/RECORD +0 -22
- {chuk_ai_session_manager-0.7.1.dist-info → chuk_ai_session_manager-0.8.dist-info}/top_level.txt +0 -0
|
@@ -2,64 +2,75 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Exception classes for the chuk session manager.
|
|
4
4
|
|
|
5
|
-
This module defines the exception hierarchy used throughout the
|
|
5
|
+
This module defines the exception hierarchy used throughout the
|
|
6
6
|
session manager to provide specific, informative error conditions
|
|
7
7
|
for various failure modes.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
|
|
10
11
|
class SessionManagerError(Exception):
|
|
11
12
|
"""
|
|
12
13
|
Base exception for all session manager errors.
|
|
13
|
-
|
|
14
|
+
|
|
14
15
|
All other session manager exceptions inherit from this class,
|
|
15
16
|
making it easy to catch all session-related errors with a single
|
|
16
17
|
except clause if needed.
|
|
17
18
|
"""
|
|
19
|
+
|
|
18
20
|
pass
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
class SessionNotFound(SessionManagerError):
|
|
22
24
|
"""
|
|
23
25
|
Raised when the requested session ID is not found in storage.
|
|
24
|
-
|
|
26
|
+
|
|
25
27
|
This exception is typically raised when:
|
|
26
28
|
- Attempting to retrieve a session with an invalid ID
|
|
27
29
|
- Accessing a session that has been deleted
|
|
28
30
|
- Using an ID that does not conform to expected format
|
|
29
31
|
"""
|
|
32
|
+
|
|
30
33
|
def __init__(self, session_id=None, message=None):
|
|
31
34
|
self.session_id = session_id
|
|
32
|
-
default_message =
|
|
35
|
+
default_message = (
|
|
36
|
+
f"Session not found: {session_id}" if session_id else "Session not found"
|
|
37
|
+
)
|
|
33
38
|
super().__init__(message or default_message)
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
class SessionAlreadyExists(SessionManagerError):
|
|
37
42
|
"""
|
|
38
43
|
Raised when attempting to create a session with an ID that already exists.
|
|
39
|
-
|
|
44
|
+
|
|
40
45
|
This exception is typically raised during session creation when:
|
|
41
46
|
- Explicitly setting an ID that conflicts with an existing session
|
|
42
47
|
- A UUID collision occurs (extremely rare)
|
|
43
48
|
"""
|
|
49
|
+
|
|
44
50
|
def __init__(self, session_id=None, message=None):
|
|
45
51
|
self.session_id = session_id
|
|
46
|
-
default_message =
|
|
52
|
+
default_message = (
|
|
53
|
+
f"Session already exists: {session_id}"
|
|
54
|
+
if session_id
|
|
55
|
+
else "Session already exists"
|
|
56
|
+
)
|
|
47
57
|
super().__init__(message or default_message)
|
|
48
58
|
|
|
49
59
|
|
|
50
60
|
class InvalidSessionOperation(SessionManagerError):
|
|
51
61
|
"""
|
|
52
62
|
Raised when attempting an invalid operation on a session.
|
|
53
|
-
|
|
63
|
+
|
|
54
64
|
This exception is typically raised when:
|
|
55
65
|
- Performing operations on a closed or archived session
|
|
56
66
|
- Adding events with incorrect sequencing or relationships
|
|
57
67
|
- Attempting unsupported operations in the current session state
|
|
58
68
|
"""
|
|
69
|
+
|
|
59
70
|
def __init__(self, operation=None, reason=None, message=None):
|
|
60
71
|
self.operation = operation
|
|
61
72
|
self.reason = reason
|
|
62
|
-
|
|
73
|
+
|
|
63
74
|
if message:
|
|
64
75
|
default_message = message
|
|
65
76
|
elif operation and reason:
|
|
@@ -68,55 +79,58 @@ class InvalidSessionOperation(SessionManagerError):
|
|
|
68
79
|
default_message = f"Invalid operation: {operation}"
|
|
69
80
|
else:
|
|
70
81
|
default_message = "Invalid session operation"
|
|
71
|
-
|
|
82
|
+
|
|
72
83
|
super().__init__(default_message)
|
|
73
84
|
|
|
74
85
|
|
|
75
86
|
class TokenLimitExceeded(SessionManagerError):
|
|
76
87
|
"""
|
|
77
88
|
Raised when a token limit is exceeded in a session operation.
|
|
78
|
-
|
|
89
|
+
|
|
79
90
|
This exception is typically raised when:
|
|
80
91
|
- Adding content that would exceed configured token limits
|
|
81
92
|
- Attempting to generate a prompt that exceeds model token limits
|
|
82
93
|
"""
|
|
94
|
+
|
|
83
95
|
def __init__(self, limit=None, actual=None, message=None):
|
|
84
96
|
self.limit = limit
|
|
85
97
|
self.actual = actual
|
|
86
|
-
|
|
98
|
+
|
|
87
99
|
if message:
|
|
88
100
|
default_message = message
|
|
89
101
|
elif limit and actual:
|
|
90
102
|
default_message = f"Token limit exceeded: {actual} > {limit}"
|
|
91
103
|
else:
|
|
92
104
|
default_message = "Token limit exceeded"
|
|
93
|
-
|
|
105
|
+
|
|
94
106
|
super().__init__(default_message)
|
|
95
107
|
|
|
96
108
|
|
|
97
109
|
class StorageError(SessionManagerError):
|
|
98
110
|
"""
|
|
99
111
|
Raised when a session storage operation fails.
|
|
100
|
-
|
|
112
|
+
|
|
101
113
|
This is a base class for more specific storage errors.
|
|
102
114
|
It can be raised directly for general storage failures.
|
|
103
115
|
"""
|
|
116
|
+
|
|
104
117
|
pass
|
|
105
118
|
|
|
106
119
|
|
|
107
120
|
class ToolProcessingError(SessionManagerError):
|
|
108
121
|
"""
|
|
109
122
|
Raised when tool processing fails in a session.
|
|
110
|
-
|
|
123
|
+
|
|
111
124
|
This exception is typically raised when:
|
|
112
125
|
- A tool execution fails after all retries
|
|
113
126
|
- Invalid tool parameters are provided
|
|
114
127
|
- Tool results cannot be properly processed
|
|
115
128
|
"""
|
|
129
|
+
|
|
116
130
|
def __init__(self, tool_name=None, reason=None, message=None):
|
|
117
131
|
self.tool_name = tool_name
|
|
118
132
|
self.reason = reason
|
|
119
|
-
|
|
133
|
+
|
|
120
134
|
if message:
|
|
121
135
|
default_message = message
|
|
122
136
|
elif tool_name and reason:
|
|
@@ -125,5 +139,5 @@ class ToolProcessingError(SessionManagerError):
|
|
|
125
139
|
default_message = f"Tool '{tool_name}' processing error"
|
|
126
140
|
else:
|
|
127
141
|
default_message = "Tool processing error"
|
|
128
|
-
|
|
129
|
-
super().__init__(default_message)
|
|
142
|
+
|
|
143
|
+
super().__init__(default_message)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# chuk_ai_session_manager/guards/__init__.py
|
|
2
|
+
"""Conversation guards and state management.
|
|
3
|
+
|
|
4
|
+
Components:
|
|
5
|
+
- ToolStateManager: Coordinator for guards, bindings, and cache
|
|
6
|
+
- BindingManager: $vN reference system for tool result tracking
|
|
7
|
+
- ResultCache: Tool result caching for deduplication
|
|
8
|
+
- UngroundedGuard: Detects missing $vN references
|
|
9
|
+
- Models: All Pydantic models for state management
|
|
10
|
+
|
|
11
|
+
Runtime guards (BudgetGuard, RunawayGuard, etc.) are imported from chuk-tool-processor.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Models
|
|
15
|
+
from chuk_ai_session_manager.guards.models import (
|
|
16
|
+
CachedToolResult,
|
|
17
|
+
CacheScope,
|
|
18
|
+
EnforcementLevel,
|
|
19
|
+
NamedVariable,
|
|
20
|
+
PerToolCallStatus,
|
|
21
|
+
ReferenceCheckResult,
|
|
22
|
+
RepairAction,
|
|
23
|
+
RunawayStatus,
|
|
24
|
+
RuntimeLimits,
|
|
25
|
+
RuntimeMode,
|
|
26
|
+
SoftBlock,
|
|
27
|
+
SoftBlockReason,
|
|
28
|
+
ToolClassification,
|
|
29
|
+
UngroundedCallResult,
|
|
30
|
+
UnusedResultAction,
|
|
31
|
+
ValueBinding,
|
|
32
|
+
ValueType,
|
|
33
|
+
classify_value_type,
|
|
34
|
+
compute_args_hash,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Sub-managers
|
|
38
|
+
from chuk_ai_session_manager.guards.bindings import BindingManager
|
|
39
|
+
from chuk_ai_session_manager.guards.cache import ResultCache
|
|
40
|
+
|
|
41
|
+
# Chat-specific guard
|
|
42
|
+
from chuk_ai_session_manager.guards.ungrounded import (
|
|
43
|
+
UngroundedGuard,
|
|
44
|
+
UngroundedGuardConfig,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Coordinator
|
|
48
|
+
from chuk_ai_session_manager.guards.manager import (
|
|
49
|
+
ToolStateManager,
|
|
50
|
+
get_tool_state,
|
|
51
|
+
reset_tool_state,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Re-export runtime guards from chuk-tool-processor
|
|
55
|
+
from chuk_tool_processor.guards import (
|
|
56
|
+
BaseGuard,
|
|
57
|
+
BudgetGuard,
|
|
58
|
+
BudgetGuardConfig,
|
|
59
|
+
BudgetState,
|
|
60
|
+
Guard,
|
|
61
|
+
GuardResult,
|
|
62
|
+
GuardVerdict,
|
|
63
|
+
PerToolGuard,
|
|
64
|
+
PerToolGuardConfig,
|
|
65
|
+
PreconditionGuard,
|
|
66
|
+
PreconditionGuardConfig,
|
|
67
|
+
RunawayGuard,
|
|
68
|
+
RunawayGuardConfig,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
# Manager
|
|
73
|
+
"ToolStateManager",
|
|
74
|
+
"get_tool_state",
|
|
75
|
+
"reset_tool_state",
|
|
76
|
+
# Sub-managers
|
|
77
|
+
"BindingManager",
|
|
78
|
+
"ResultCache",
|
|
79
|
+
# Guards (from chuk-tool-processor)
|
|
80
|
+
"BaseGuard",
|
|
81
|
+
"Guard",
|
|
82
|
+
"GuardResult",
|
|
83
|
+
"GuardVerdict",
|
|
84
|
+
"BudgetGuard",
|
|
85
|
+
"BudgetGuardConfig",
|
|
86
|
+
"BudgetState",
|
|
87
|
+
"PerToolGuard",
|
|
88
|
+
"PerToolGuardConfig",
|
|
89
|
+
"PreconditionGuard",
|
|
90
|
+
"PreconditionGuardConfig",
|
|
91
|
+
"RunawayGuard",
|
|
92
|
+
"RunawayGuardConfig",
|
|
93
|
+
# Chat-specific guard
|
|
94
|
+
"UngroundedGuard",
|
|
95
|
+
"UngroundedGuardConfig",
|
|
96
|
+
# Enums and Constants
|
|
97
|
+
"CacheScope",
|
|
98
|
+
"EnforcementLevel",
|
|
99
|
+
"RuntimeMode",
|
|
100
|
+
"UnusedResultAction",
|
|
101
|
+
"ValueType",
|
|
102
|
+
"ToolClassification",
|
|
103
|
+
# Models
|
|
104
|
+
"CachedToolResult",
|
|
105
|
+
"NamedVariable",
|
|
106
|
+
"PerToolCallStatus",
|
|
107
|
+
"ReferenceCheckResult",
|
|
108
|
+
"RepairAction",
|
|
109
|
+
"RunawayStatus",
|
|
110
|
+
"RuntimeLimits",
|
|
111
|
+
"SoftBlock",
|
|
112
|
+
"SoftBlockReason",
|
|
113
|
+
"UngroundedCallResult",
|
|
114
|
+
"ValueBinding",
|
|
115
|
+
# Helpers
|
|
116
|
+
"classify_value_type",
|
|
117
|
+
"compute_args_hash",
|
|
118
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# chuk_ai_session_manager/guards/bindings.py
|
|
2
|
+
"""Value binding system for $vN references.
|
|
3
|
+
|
|
4
|
+
Every tool result gets assigned a stable ID (v1, v2, v3...) that can
|
|
5
|
+
be referenced in subsequent tool calls using $vN syntax.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from chuk_ai_session_manager.guards.models import (
|
|
17
|
+
ValueBinding,
|
|
18
|
+
classify_value_type,
|
|
19
|
+
compute_args_hash,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Reference pattern: $v1, $v2, ${v1}, ${myalias}
|
|
23
|
+
REFERENCE_PATTERN = re.compile(r"\$\{?([a-zA-Z_][a-zA-Z0-9_]*|v\d+)\}?")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BindingManager(BaseModel):
|
|
27
|
+
"""Manages value bindings for $vN references.
|
|
28
|
+
|
|
29
|
+
Pydantic-native implementation with all state in the model.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
bindings: dict[str, ValueBinding] = Field(default_factory=dict)
|
|
33
|
+
alias_to_id: dict[str, str] = Field(default_factory=dict)
|
|
34
|
+
next_id: int = Field(default=1)
|
|
35
|
+
|
|
36
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
37
|
+
|
|
38
|
+
def bind(
|
|
39
|
+
self,
|
|
40
|
+
tool_name: str,
|
|
41
|
+
arguments: dict[str, Any],
|
|
42
|
+
value: Any,
|
|
43
|
+
aliases: list[str] | None = None,
|
|
44
|
+
) -> ValueBinding:
|
|
45
|
+
"""Bind a tool result to a value ID.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
tool_name: Name of the tool that produced this value
|
|
49
|
+
arguments: Arguments passed to the tool
|
|
50
|
+
value: The result value
|
|
51
|
+
aliases: Optional model-provided names
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The created ValueBinding
|
|
55
|
+
"""
|
|
56
|
+
value_id = f"v{self.next_id}"
|
|
57
|
+
self.next_id += 1
|
|
58
|
+
|
|
59
|
+
binding = ValueBinding(
|
|
60
|
+
id=value_id,
|
|
61
|
+
tool_name=tool_name,
|
|
62
|
+
args_hash=compute_args_hash(arguments),
|
|
63
|
+
raw_value=value,
|
|
64
|
+
value_type=classify_value_type(value),
|
|
65
|
+
aliases=aliases or [],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.bindings[value_id] = binding
|
|
69
|
+
|
|
70
|
+
# Register aliases
|
|
71
|
+
for alias in binding.aliases:
|
|
72
|
+
self.alias_to_id[alias] = value_id
|
|
73
|
+
|
|
74
|
+
return binding
|
|
75
|
+
|
|
76
|
+
def get(self, ref: str) -> ValueBinding | None:
|
|
77
|
+
"""Get a binding by ID or alias."""
|
|
78
|
+
if ref in self.bindings:
|
|
79
|
+
return self.bindings[ref]
|
|
80
|
+
if ref in self.alias_to_id:
|
|
81
|
+
value_id = self.alias_to_id[ref]
|
|
82
|
+
return self.bindings.get(value_id)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def add_alias(self, value_id: str, alias: str) -> bool:
|
|
86
|
+
"""Add an alias to an existing binding."""
|
|
87
|
+
if value_id not in self.bindings:
|
|
88
|
+
return False
|
|
89
|
+
binding = self.bindings[value_id]
|
|
90
|
+
if alias not in binding.aliases:
|
|
91
|
+
binding.aliases.append(alias)
|
|
92
|
+
self.alias_to_id[alias] = value_id
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def mark_used(self, ref: str, used_in: str) -> None:
|
|
96
|
+
"""Mark a value as having been used."""
|
|
97
|
+
binding = self.get(ref)
|
|
98
|
+
if binding:
|
|
99
|
+
binding.used = True
|
|
100
|
+
binding.used_in.append(used_in)
|
|
101
|
+
|
|
102
|
+
def get_numeric_values(self) -> set[float]:
|
|
103
|
+
"""Get all numeric values from bindings.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Set of float values from all bindings that have numeric raw_value
|
|
107
|
+
"""
|
|
108
|
+
values: set[float] = set()
|
|
109
|
+
for binding in self.bindings.values():
|
|
110
|
+
if isinstance(binding.raw_value, (int, float)):
|
|
111
|
+
values.add(float(binding.raw_value))
|
|
112
|
+
return values
|
|
113
|
+
|
|
114
|
+
def resolve_references(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
"""Resolve $vN references in arguments to actual values."""
|
|
116
|
+
args_str = json.dumps(arguments, default=str)
|
|
117
|
+
|
|
118
|
+
def replace_ref(match: re.Match[str]) -> str:
|
|
119
|
+
ref = match.group(1)
|
|
120
|
+
binding = self.get(ref)
|
|
121
|
+
if binding:
|
|
122
|
+
self.mark_used(ref, "arg_resolution")
|
|
123
|
+
value = binding.typed_value
|
|
124
|
+
if isinstance(value, (int, float)):
|
|
125
|
+
return str(value)
|
|
126
|
+
return json.dumps(value)
|
|
127
|
+
return str(match.group(0))
|
|
128
|
+
|
|
129
|
+
resolved_str = REFERENCE_PATTERN.sub(replace_ref, args_str)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
result: dict[str, Any] = json.loads(resolved_str)
|
|
133
|
+
return result
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
return arguments
|
|
136
|
+
|
|
137
|
+
def check_references(
|
|
138
|
+
self, arguments: dict[str, Any]
|
|
139
|
+
) -> tuple[bool, list[str], dict[str, Any]]:
|
|
140
|
+
"""Check if all $vN references in arguments exist.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Tuple of (all_valid, missing_refs, resolved_values)
|
|
144
|
+
"""
|
|
145
|
+
args_str = json.dumps(arguments, default=str)
|
|
146
|
+
matches = REFERENCE_PATTERN.findall(args_str)
|
|
147
|
+
|
|
148
|
+
missing: list[str] = []
|
|
149
|
+
resolved: dict[str, Any] = {}
|
|
150
|
+
|
|
151
|
+
for ref in matches:
|
|
152
|
+
binding = self.get(ref)
|
|
153
|
+
if binding is None:
|
|
154
|
+
missing.append(ref)
|
|
155
|
+
else:
|
|
156
|
+
resolved[ref] = binding.typed_value
|
|
157
|
+
|
|
158
|
+
return len(missing) == 0, missing, resolved
|
|
159
|
+
|
|
160
|
+
def find_by_value(
|
|
161
|
+
self, value: float, tolerance: float = 0.0001
|
|
162
|
+
) -> ValueBinding | None:
|
|
163
|
+
"""Find a binding with a matching value."""
|
|
164
|
+
for binding in self.bindings.values():
|
|
165
|
+
try:
|
|
166
|
+
binding_val = float(binding.typed_value)
|
|
167
|
+
if value == binding_val:
|
|
168
|
+
return binding
|
|
169
|
+
if abs(value) > 1e-10 and abs(binding_val) > 1e-10:
|
|
170
|
+
if (
|
|
171
|
+
abs(value - binding_val) / max(abs(value), abs(binding_val))
|
|
172
|
+
< tolerance
|
|
173
|
+
):
|
|
174
|
+
return binding
|
|
175
|
+
except (ValueError, TypeError):
|
|
176
|
+
continue
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def get_unused(self) -> list[ValueBinding]:
|
|
180
|
+
"""Get all bindings that haven't been used."""
|
|
181
|
+
return [b for b in self.bindings.values() if not b.used]
|
|
182
|
+
|
|
183
|
+
def format_for_model(self) -> str:
|
|
184
|
+
"""Format all bindings for display to the model."""
|
|
185
|
+
if not self.bindings:
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
lines = ["**Available Values (reference with $vN):**"]
|
|
189
|
+
for binding in self.bindings.values():
|
|
190
|
+
status = "✓" if binding.used else "○"
|
|
191
|
+
lines.append(f" {status} {binding.format_for_model()}")
|
|
192
|
+
|
|
193
|
+
return "\n".join(lines)
|
|
194
|
+
|
|
195
|
+
def format_unused_warning(self) -> str:
|
|
196
|
+
"""Generate warning about unused tool results."""
|
|
197
|
+
unused = self.get_unused()
|
|
198
|
+
if not unused:
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
ids = ", ".join(f"${b.id}" for b in unused)
|
|
202
|
+
return (
|
|
203
|
+
f"**Note:** You called tools producing {ids} but haven't referenced them. "
|
|
204
|
+
"Either use these values or explain why they're not needed."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def reset(self) -> None:
|
|
208
|
+
"""Reset all bindings."""
|
|
209
|
+
self.bindings.clear()
|
|
210
|
+
self.alias_to_id.clear()
|
|
211
|
+
self.next_id = 1
|
|
212
|
+
|
|
213
|
+
def __len__(self) -> int:
|
|
214
|
+
return len(self.bindings)
|
|
215
|
+
|
|
216
|
+
def __bool__(self) -> bool:
|
|
217
|
+
return bool(self.bindings)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# chuk_ai_session_manager/guards/cache.py
|
|
2
|
+
"""Tool result caching.
|
|
3
|
+
|
|
4
|
+
Caches tool call results so duplicates return cached values.
|
|
5
|
+
Prevents the model from re-calling tools unnecessarily.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from chuk_ai_session_manager.guards.models import CachedToolResult, NamedVariable
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResultCache(BaseModel):
|
|
22
|
+
"""Caches tool results for deduplication.
|
|
23
|
+
|
|
24
|
+
Pydantic-native implementation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
cache: dict[str, CachedToolResult] = Field(default_factory=dict)
|
|
28
|
+
variables: dict[str, NamedVariable] = Field(default_factory=dict)
|
|
29
|
+
call_order: list[str] = Field(default_factory=list)
|
|
30
|
+
max_size: int = Field(default=100)
|
|
31
|
+
duplicate_count: int = Field(default=0)
|
|
32
|
+
|
|
33
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
34
|
+
|
|
35
|
+
def get(self, tool_name: str, arguments: dict[str, Any]) -> CachedToolResult | None:
|
|
36
|
+
"""Check if we have a cached result for this exact tool call."""
|
|
37
|
+
signature = self._make_signature(tool_name, arguments)
|
|
38
|
+
cached = self.cache.get(signature)
|
|
39
|
+
if cached:
|
|
40
|
+
cached.call_count += 1
|
|
41
|
+
self.duplicate_count += 1
|
|
42
|
+
log.info(f"Cache hit for {tool_name} (call #{cached.call_count})")
|
|
43
|
+
return cached
|
|
44
|
+
|
|
45
|
+
def put(
|
|
46
|
+
self,
|
|
47
|
+
tool_name: str,
|
|
48
|
+
arguments: dict[str, Any],
|
|
49
|
+
result: Any,
|
|
50
|
+
) -> CachedToolResult:
|
|
51
|
+
"""Cache a tool result."""
|
|
52
|
+
# Evict if full
|
|
53
|
+
if len(self.cache) >= self.max_size:
|
|
54
|
+
self._evict_oldest()
|
|
55
|
+
|
|
56
|
+
signature = self._make_signature(tool_name, arguments)
|
|
57
|
+
cached = CachedToolResult(
|
|
58
|
+
tool_name=tool_name,
|
|
59
|
+
arguments=arguments,
|
|
60
|
+
result=result,
|
|
61
|
+
)
|
|
62
|
+
self.cache[signature] = cached
|
|
63
|
+
self.call_order.append(signature)
|
|
64
|
+
|
|
65
|
+
log.debug(f"Cached result for {tool_name}: {cached.format_compact()}")
|
|
66
|
+
return cached
|
|
67
|
+
|
|
68
|
+
def store_variable(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
value: float,
|
|
72
|
+
units: str | None = None,
|
|
73
|
+
source_tool: str | None = None,
|
|
74
|
+
source_args: dict[str, Any] | None = None,
|
|
75
|
+
) -> NamedVariable:
|
|
76
|
+
"""Store a named variable from a computation."""
|
|
77
|
+
var = NamedVariable(
|
|
78
|
+
name=name,
|
|
79
|
+
value=value,
|
|
80
|
+
units=units,
|
|
81
|
+
source_tool=source_tool,
|
|
82
|
+
source_args=source_args,
|
|
83
|
+
)
|
|
84
|
+
self.variables[name] = var
|
|
85
|
+
log.debug(f"Stored variable: {var.format_compact()}")
|
|
86
|
+
return var
|
|
87
|
+
|
|
88
|
+
def get_variable(self, name: str) -> NamedVariable | None:
|
|
89
|
+
"""Get a stored variable by name."""
|
|
90
|
+
return self.variables.get(name)
|
|
91
|
+
|
|
92
|
+
def format_state(self, max_items: int = 10) -> str:
|
|
93
|
+
"""Generate compact state summary for model context."""
|
|
94
|
+
lines = []
|
|
95
|
+
|
|
96
|
+
# Named variables first
|
|
97
|
+
if self.variables:
|
|
98
|
+
lines.append("**Stored Variables:**")
|
|
99
|
+
for var in list(self.variables.values())[:max_items]:
|
|
100
|
+
lines.append(f" {var.format_compact()}")
|
|
101
|
+
|
|
102
|
+
# Recent tool results
|
|
103
|
+
recent_sigs = self.call_order[-max_items:]
|
|
104
|
+
recent_results = [self.cache[sig] for sig in recent_sigs if sig in self.cache]
|
|
105
|
+
|
|
106
|
+
if recent_results:
|
|
107
|
+
if lines:
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append("**Computed Values:**")
|
|
110
|
+
for cached in recent_results:
|
|
111
|
+
lines.append(f" {cached.format_compact()}")
|
|
112
|
+
|
|
113
|
+
return "\n".join(lines) if lines else ""
|
|
114
|
+
|
|
115
|
+
def format_duplicate_message(
|
|
116
|
+
self, tool_name: str, arguments: dict[str, Any]
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Generate message when duplicate call is detected."""
|
|
119
|
+
cached = self.get(tool_name, arguments)
|
|
120
|
+
if not cached:
|
|
121
|
+
return f"Tool {tool_name} was called but no cached result available."
|
|
122
|
+
|
|
123
|
+
lines = [
|
|
124
|
+
f"**Cached result for {tool_name}:** {cached.result}",
|
|
125
|
+
"",
|
|
126
|
+
"This value was already computed. Use it directly.",
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
state = self.format_state()
|
|
131
|
+
if state:
|
|
132
|
+
lines.append(state)
|
|
133
|
+
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
def get_stats(self) -> dict[str, Any]:
|
|
137
|
+
"""Get cache statistics."""
|
|
138
|
+
return {
|
|
139
|
+
"total_cached": len(self.cache),
|
|
140
|
+
"total_variables": len(self.variables),
|
|
141
|
+
"duplicate_calls": self.duplicate_count,
|
|
142
|
+
"call_order_length": len(self.call_order),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def reset(self) -> None:
|
|
146
|
+
"""Clear all cached state."""
|
|
147
|
+
self.cache.clear()
|
|
148
|
+
self.variables.clear()
|
|
149
|
+
self.call_order.clear()
|
|
150
|
+
self.duplicate_count = 0
|
|
151
|
+
|
|
152
|
+
def _make_signature(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
153
|
+
"""Create unique signature for a tool call."""
|
|
154
|
+
args_str = json.dumps(arguments, sort_keys=True, default=str)
|
|
155
|
+
return f"{tool_name}:{args_str}"
|
|
156
|
+
|
|
157
|
+
def _evict_oldest(self) -> None:
|
|
158
|
+
"""Evict oldest cached result."""
|
|
159
|
+
if self.call_order:
|
|
160
|
+
oldest_sig = self.call_order.pop(0)
|
|
161
|
+
if oldest_sig in self.cache:
|
|
162
|
+
del self.cache[oldest_sig]
|
|
163
|
+
log.debug("Evicted oldest cache entry")
|