remdb 0.3.103__py3-none-any.whl → 0.3.141__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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +51 -27
- rem/agentic/mcp/tool_wrapper.py +155 -18
- rem/agentic/otel/setup.py +93 -4
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +195 -46
- rem/agentic/schema.py +361 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/main.py +85 -16
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +18 -4
- rem/api/mcp_router/tools.py +394 -16
- rem/api/routers/admin.py +218 -1
- rem/api/routers/chat/completions.py +280 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +177 -3
- rem/api/routers/feedback.py +142 -329
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +13 -13
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +354 -143
- rem/cli/commands/experiments.py +436 -30
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +92 -45
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +29 -6
- rem/config.py +8 -1
- rem/models/core/experiment.py +54 -0
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/shared_session.py +2 -28
- rem/registry.py +10 -4
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/content/service.py +30 -8
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +151 -26
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +531 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +7 -0
- rem/services/session/reload.py +1 -1
- rem/settings.py +288 -16
- rem/sql/background_indexes.sql +19 -24
- rem/sql/migrations/001_install.sql +252 -69
- rem/sql/migrations/002_install_models.sql +2197 -619
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/schema_loader.py +110 -15
- rem/utils/sql_paths.py +146 -0
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/METADATA +300 -215
- {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/RECORD +73 -64
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/WHEEL +0 -0
- {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/entry_points.txt +0 -0
|
@@ -265,6 +265,8 @@ async def stream_simulator_events(
|
|
|
265
265
|
message_id=message_id,
|
|
266
266
|
in_reply_to=in_reply_to,
|
|
267
267
|
session_id=session_id,
|
|
268
|
+
# Session info
|
|
269
|
+
session_name="SSE Demo Session",
|
|
268
270
|
# Quality indicators
|
|
269
271
|
confidence=0.95,
|
|
270
272
|
sources=["rem/api/routers/chat/sse_events.py", "rem/agentic/agents/sse_simulator.py"],
|
rem/agentic/context.py
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
Agent execution context and configuration.
|
|
3
3
|
|
|
4
4
|
Design pattern for session context that can be constructed from:
|
|
5
|
-
- HTTP headers (X-User-Id, X-Session-Id, X-Model-Name)
|
|
5
|
+
- HTTP headers (X-User-Id, X-Session-Id, X-Model-Name, X-Is-Eval, etc.)
|
|
6
6
|
- Direct instantiation for testing/CLI
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Headers Mapping:
|
|
9
|
+
X-User-Id → context.user_id
|
|
10
|
+
X-Tenant-Id → context.tenant_id (default: "default")
|
|
11
|
+
X-Session-Id → context.session_id
|
|
12
|
+
X-Agent-Schema → context.agent_schema_uri (default: "rem")
|
|
13
|
+
X-Model-Name → context.default_model
|
|
14
|
+
X-Is-Eval → context.is_eval (marks session as evaluation)
|
|
15
|
+
|
|
16
|
+
Key Design Pattern:
|
|
9
17
|
- AgentContext is passed to agent factory, not stored in agents
|
|
10
18
|
- Enables session tracking across API, CLI, and test execution
|
|
11
19
|
- Supports header-based configuration override (model, schema URI)
|
|
@@ -66,6 +74,11 @@ class AgentContext(BaseModel):
|
|
|
66
74
|
description="Agent schema URI (e.g., 'rem-agents-query-agent')",
|
|
67
75
|
)
|
|
68
76
|
|
|
77
|
+
is_eval: bool = Field(
|
|
78
|
+
default=False,
|
|
79
|
+
description="Whether this is an evaluation session (set via X-Is-Eval header)",
|
|
80
|
+
)
|
|
81
|
+
|
|
69
82
|
model_config = {"populate_by_name": True}
|
|
70
83
|
|
|
71
84
|
@staticmethod
|
|
@@ -73,43 +86,47 @@ class AgentContext(BaseModel):
|
|
|
73
86
|
user_id: str | None,
|
|
74
87
|
source: str = "context",
|
|
75
88
|
default: str | None = None,
|
|
76
|
-
) -> str:
|
|
89
|
+
) -> str | None:
|
|
77
90
|
"""
|
|
78
|
-
Get user_id or
|
|
91
|
+
Get user_id or return None for anonymous access.
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
User ID convention:
|
|
94
|
+
- user_id is a deterministic UUID5 hash of the user's email address
|
|
95
|
+
- Use rem.utils.user_id.email_to_user_id(email) to generate
|
|
96
|
+
- The JWT's `sub` claim is NOT directly used as user_id
|
|
97
|
+
- Authentication middleware extracts email from JWT and hashes it
|
|
98
|
+
|
|
99
|
+
When user_id is None, queries return data with user_id IS NULL
|
|
100
|
+
(shared/public data). This is intentional - no fake user IDs.
|
|
82
101
|
|
|
83
102
|
Args:
|
|
84
|
-
user_id: User identifier (may be None)
|
|
103
|
+
user_id: User identifier (UUID5 hash of email, may be None for anonymous)
|
|
85
104
|
source: Source of the call (for logging clarity)
|
|
86
|
-
default:
|
|
105
|
+
default: Explicit default (only for testing, not auto-generated)
|
|
87
106
|
|
|
88
107
|
Returns:
|
|
89
|
-
user_id if provided,
|
|
108
|
+
user_id if provided, explicit default if provided, otherwise None
|
|
90
109
|
|
|
91
110
|
Example:
|
|
92
|
-
#
|
|
93
|
-
user_id
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# In API endpoint
|
|
98
|
-
user_id = AgentContext.get_user_id_or_default(
|
|
99
|
-
temp_context.user_id, source="chat_completions"
|
|
100
|
-
)
|
|
111
|
+
# Generate user_id from email (done by auth middleware)
|
|
112
|
+
from rem.utils.user_id import email_to_user_id
|
|
113
|
+
user_id = email_to_user_id("alice@example.com")
|
|
114
|
+
# -> "2c5ea4c0-4067-5fef-942d-0a20124e06d8"
|
|
101
115
|
|
|
102
|
-
# In
|
|
116
|
+
# In MCP tool - anonymous user sees shared data
|
|
103
117
|
user_id = AgentContext.get_user_id_or_default(
|
|
104
|
-
|
|
118
|
+
user_id, source="ask_rem_agent"
|
|
105
119
|
)
|
|
120
|
+
# Returns None if not authenticated -> queries WHERE user_id IS NULL
|
|
106
121
|
"""
|
|
107
|
-
if user_id is None:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
logger.debug(f"
|
|
111
|
-
return
|
|
112
|
-
return
|
|
122
|
+
if user_id is not None:
|
|
123
|
+
return user_id
|
|
124
|
+
if default is not None:
|
|
125
|
+
logger.debug(f"Using explicit default user_id '{default}' from {source}")
|
|
126
|
+
return default
|
|
127
|
+
# No fake user IDs - return None for anonymous/unauthenticated
|
|
128
|
+
logger.debug(f"No user_id from {source}, using None (anonymous/shared data)")
|
|
129
|
+
return None
|
|
113
130
|
|
|
114
131
|
@classmethod
|
|
115
132
|
def from_headers(cls, headers: dict[str, str]) -> "AgentContext":
|
|
@@ -122,6 +139,7 @@ class AgentContext(BaseModel):
|
|
|
122
139
|
- X-Session-Id: Session identifier
|
|
123
140
|
- X-Model-Name: Model override
|
|
124
141
|
- X-Agent-Schema: Agent schema URI
|
|
142
|
+
- X-Is-Eval: Whether this is an evaluation session (true/false)
|
|
125
143
|
|
|
126
144
|
Args:
|
|
127
145
|
headers: Dictionary of HTTP headers (case-insensitive)
|
|
@@ -134,17 +152,23 @@ class AgentContext(BaseModel):
|
|
|
134
152
|
"X-User-Id": "user123",
|
|
135
153
|
"X-Tenant-Id": "acme-corp",
|
|
136
154
|
"X-Session-Id": "sess-456",
|
|
137
|
-
"X-Model-Name": "anthropic:claude-opus-4-20250514"
|
|
155
|
+
"X-Model-Name": "anthropic:claude-opus-4-20250514",
|
|
156
|
+
"X-Is-Eval": "true"
|
|
138
157
|
}
|
|
139
158
|
context = AgentContext.from_headers(headers)
|
|
140
159
|
"""
|
|
141
160
|
# Normalize header keys to lowercase for case-insensitive lookup
|
|
142
161
|
normalized = {k.lower(): v for k, v in headers.items()}
|
|
143
162
|
|
|
163
|
+
# Parse X-Is-Eval header (accepts "true", "1", "yes" as truthy)
|
|
164
|
+
is_eval_str = normalized.get("x-is-eval", "").lower()
|
|
165
|
+
is_eval = is_eval_str in ("true", "1", "yes")
|
|
166
|
+
|
|
144
167
|
return cls(
|
|
145
168
|
user_id=normalized.get("x-user-id"),
|
|
146
169
|
tenant_id=normalized.get("x-tenant-id", "default"),
|
|
147
170
|
session_id=normalized.get("x-session-id"),
|
|
148
171
|
default_model=normalized.get("x-model-name") or settings.llm.default_model,
|
|
149
172
|
agent_schema_uri=normalized.get("x-agent-schema"),
|
|
173
|
+
is_eval=is_eval,
|
|
150
174
|
)
|
rem/agentic/mcp/tool_wrapper.py
CHANGED
|
@@ -28,7 +28,12 @@ def create_pydantic_tool(func: Callable[..., Any]) -> Tool:
|
|
|
28
28
|
return Tool(func)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def create_mcp_tool_wrapper(
|
|
31
|
+
def create_mcp_tool_wrapper(
|
|
32
|
+
tool_name: str,
|
|
33
|
+
mcp_tool: Any,
|
|
34
|
+
user_id: str | None = None,
|
|
35
|
+
description_suffix: str | None = None,
|
|
36
|
+
) -> Tool:
|
|
32
37
|
"""
|
|
33
38
|
Create a Pydantic AI Tool from a FastMCP FunctionTool.
|
|
34
39
|
|
|
@@ -40,6 +45,8 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
|
|
|
40
45
|
tool_name: Name of the MCP tool
|
|
41
46
|
mcp_tool: The FastMCP FunctionTool object
|
|
42
47
|
user_id: Optional user_id to inject into tool calls
|
|
48
|
+
description_suffix: Optional text to append to the tool's docstring.
|
|
49
|
+
Used to add schema-specific context (e.g., default table for search_rem).
|
|
43
50
|
|
|
44
51
|
Returns:
|
|
45
52
|
A Pydantic AI Tool instance
|
|
@@ -52,7 +59,11 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
|
|
|
52
59
|
sig = inspect.signature(tool_func)
|
|
53
60
|
has_user_id = "user_id" in sig.parameters
|
|
54
61
|
|
|
55
|
-
#
|
|
62
|
+
# Build the docstring with optional suffix
|
|
63
|
+
base_doc = tool_func.__doc__ or ""
|
|
64
|
+
final_doc = base_doc + description_suffix if description_suffix else base_doc
|
|
65
|
+
|
|
66
|
+
# If we need to inject user_id or modify docstring, create a wrapper
|
|
56
67
|
# Otherwise, use the function directly for better signature preservation
|
|
57
68
|
if user_id and has_user_id:
|
|
58
69
|
async def wrapped_tool(**kwargs) -> Any:
|
|
@@ -69,39 +80,165 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
|
|
|
69
80
|
|
|
70
81
|
# Copy signature from original function for Pydantic AI inspection
|
|
71
82
|
wrapped_tool.__name__ = tool_name
|
|
72
|
-
wrapped_tool.__doc__ =
|
|
83
|
+
wrapped_tool.__doc__ = final_doc
|
|
73
84
|
wrapped_tool.__annotations__ = tool_func.__annotations__
|
|
74
85
|
wrapped_tool.__signature__ = sig # Important: preserve full signature
|
|
75
86
|
|
|
76
87
|
logger.debug(f"Creating MCP tool wrapper with user_id injection: {tool_name}")
|
|
77
88
|
return Tool(wrapped_tool)
|
|
89
|
+
elif description_suffix:
|
|
90
|
+
# Need to wrap just for docstring modification
|
|
91
|
+
async def wrapped_tool(**kwargs) -> Any:
|
|
92
|
+
"""Wrapper for docstring modification."""
|
|
93
|
+
valid_params = set(sig.parameters.keys())
|
|
94
|
+
filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_params}
|
|
95
|
+
return await tool_func(**filtered_kwargs)
|
|
96
|
+
|
|
97
|
+
wrapped_tool.__name__ = tool_name
|
|
98
|
+
wrapped_tool.__doc__ = final_doc
|
|
99
|
+
wrapped_tool.__annotations__ = tool_func.__annotations__
|
|
100
|
+
wrapped_tool.__signature__ = sig
|
|
101
|
+
|
|
102
|
+
logger.debug(f"Creating MCP tool wrapper with description suffix: {tool_name}")
|
|
103
|
+
return Tool(wrapped_tool)
|
|
78
104
|
else:
|
|
79
105
|
# No injection needed - use original function directly
|
|
80
106
|
logger.debug(f"Creating MCP tool wrapper (no injection): {tool_name}")
|
|
81
107
|
return Tool(tool_func)
|
|
82
108
|
|
|
83
109
|
|
|
84
|
-
def create_resource_tool(uri: str, usage: str) -> Tool:
|
|
110
|
+
def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> Tool:
|
|
85
111
|
"""
|
|
86
112
|
Build a Tool instance from an MCP resource URI.
|
|
87
113
|
|
|
88
|
-
|
|
89
|
-
|
|
114
|
+
Creates a tool that fetches the resource content when called.
|
|
115
|
+
Resources declared in agent YAML become callable tools - this eliminates
|
|
116
|
+
the artificial MCP distinction between tools and resources.
|
|
117
|
+
|
|
118
|
+
Supports both:
|
|
119
|
+
- Concrete URIs: "rem://schemas" -> tool with no parameters
|
|
120
|
+
- Template URIs: "patient-profile://field/{field_key}" -> tool with field_key parameter
|
|
90
121
|
|
|
91
122
|
Args:
|
|
92
|
-
uri: The resource URI (
|
|
93
|
-
usage: The description of
|
|
123
|
+
uri: The resource URI (concrete or template with {variable} placeholders).
|
|
124
|
+
usage: The description of what this resource provides.
|
|
125
|
+
mcp_server: Optional FastMCP server instance to resolve resources from.
|
|
126
|
+
If provided, resources are resolved from this server's registry.
|
|
127
|
+
If not provided, falls back to REM's built-in load_resource().
|
|
94
128
|
|
|
95
129
|
Returns:
|
|
96
|
-
A Pydantic AI Tool instance.
|
|
97
|
-
"""
|
|
98
|
-
# Placeholder function that would read the resource
|
|
99
|
-
def read_resource():
|
|
100
|
-
"""Reads content from a resource URI."""
|
|
101
|
-
return f"Content of {uri}"
|
|
130
|
+
A Pydantic AI Tool instance that fetches the resource.
|
|
102
131
|
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
Example:
|
|
133
|
+
# Concrete URI -> no-param tool
|
|
134
|
+
tool = create_resource_tool("rem://schemas", "List all agent schemas")
|
|
105
135
|
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
# Template URI -> parameterized tool
|
|
137
|
+
tool = create_resource_tool("patient-profile://field/{field_key}", "Get field definition", mcp_server=mcp)
|
|
138
|
+
# Agent calls: get_patient_profile_field(field_key="safety.suicidality")
|
|
139
|
+
"""
|
|
140
|
+
import json
|
|
141
|
+
import re
|
|
142
|
+
|
|
143
|
+
# Extract template variables from URI (e.g., {field_key}, {domain_name})
|
|
144
|
+
template_vars = re.findall(r'\{([^}]+)\}', uri)
|
|
145
|
+
|
|
146
|
+
# Parse URI to create function name (strip template vars for cleaner name)
|
|
147
|
+
clean_uri = re.sub(r'\{[^}]+\}', '', uri)
|
|
148
|
+
parts = clean_uri.replace("://", "_").replace("-", "_").replace("/", "_").replace(".", "_")
|
|
149
|
+
parts = re.sub(r'_+', '_', parts).strip('_') # Clean up multiple underscores
|
|
150
|
+
func_name = f"get_{parts}"
|
|
151
|
+
|
|
152
|
+
# Build description including parameter info
|
|
153
|
+
description = usage or f"Fetch {uri} resource"
|
|
154
|
+
if template_vars:
|
|
155
|
+
param_desc = ", ".join(template_vars)
|
|
156
|
+
description = f"{description}\n\nParameters: {param_desc}"
|
|
157
|
+
|
|
158
|
+
if template_vars:
|
|
159
|
+
# Template URI -> create parameterized tool
|
|
160
|
+
async def wrapper(**kwargs: Any) -> str:
|
|
161
|
+
"""Fetch MCP resource with substituted parameters."""
|
|
162
|
+
import asyncio
|
|
163
|
+
import inspect
|
|
164
|
+
|
|
165
|
+
# Try to resolve from MCP server's resource templates first
|
|
166
|
+
if mcp_server is not None:
|
|
167
|
+
try:
|
|
168
|
+
# Get resource templates from MCP server
|
|
169
|
+
templates = await mcp_server.get_resource_templates()
|
|
170
|
+
if uri in templates:
|
|
171
|
+
template = templates[uri]
|
|
172
|
+
# Call the template's underlying function directly
|
|
173
|
+
# The fn expects the template variables as kwargs
|
|
174
|
+
fn_result = template.fn(**kwargs)
|
|
175
|
+
# Handle both sync and async functions
|
|
176
|
+
if inspect.iscoroutine(fn_result):
|
|
177
|
+
fn_result = await fn_result
|
|
178
|
+
if isinstance(fn_result, str):
|
|
179
|
+
return fn_result
|
|
180
|
+
return json.dumps(fn_result, indent=2)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.warning(f"Failed to resolve resource {uri} from MCP server: {e}")
|
|
183
|
+
|
|
184
|
+
# Fallback: substitute template variables and use load_resource
|
|
185
|
+
resolved_uri = uri
|
|
186
|
+
for var in template_vars:
|
|
187
|
+
if var in kwargs:
|
|
188
|
+
resolved_uri = resolved_uri.replace(f"{{{var}}}", str(kwargs[var]))
|
|
189
|
+
else:
|
|
190
|
+
return json.dumps({"error": f"Missing required parameter: {var}"})
|
|
191
|
+
|
|
192
|
+
from rem.api.mcp_router.resources import load_resource
|
|
193
|
+
result = await load_resource(resolved_uri)
|
|
194
|
+
if isinstance(result, str):
|
|
195
|
+
return result
|
|
196
|
+
return json.dumps(result, indent=2)
|
|
197
|
+
|
|
198
|
+
# Build parameter annotations for Pydantic AI
|
|
199
|
+
wrapper.__name__ = func_name
|
|
200
|
+
wrapper.__doc__ = description
|
|
201
|
+
# Add type hints for parameters
|
|
202
|
+
wrapper.__annotations__ = {var: str for var in template_vars}
|
|
203
|
+
wrapper.__annotations__['return'] = str
|
|
204
|
+
|
|
205
|
+
logger.info(f"Built parameterized resource tool: {func_name} (uri: {uri}, params: {template_vars})")
|
|
206
|
+
else:
|
|
207
|
+
# Concrete URI -> no-param tool
|
|
208
|
+
async def wrapper(**kwargs: Any) -> str:
|
|
209
|
+
"""Fetch MCP resource and return contents."""
|
|
210
|
+
import asyncio
|
|
211
|
+
import inspect
|
|
212
|
+
|
|
213
|
+
if kwargs:
|
|
214
|
+
logger.warning(f"Resource tool {func_name} called with unexpected kwargs: {list(kwargs.keys())}")
|
|
215
|
+
|
|
216
|
+
# Try to resolve from MCP server's resources first
|
|
217
|
+
if mcp_server is not None:
|
|
218
|
+
try:
|
|
219
|
+
resources = await mcp_server.get_resources()
|
|
220
|
+
if uri in resources:
|
|
221
|
+
resource = resources[uri]
|
|
222
|
+
# Call the resource's underlying function
|
|
223
|
+
fn_result = resource.fn()
|
|
224
|
+
if inspect.iscoroutine(fn_result):
|
|
225
|
+
fn_result = await fn_result
|
|
226
|
+
if isinstance(fn_result, str):
|
|
227
|
+
return fn_result
|
|
228
|
+
return json.dumps(fn_result, indent=2)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.warning(f"Failed to resolve resource {uri} from MCP server: {e}")
|
|
231
|
+
|
|
232
|
+
# Fallback to load_resource
|
|
233
|
+
from rem.api.mcp_router.resources import load_resource
|
|
234
|
+
result = await load_resource(uri)
|
|
235
|
+
if isinstance(result, str):
|
|
236
|
+
return result
|
|
237
|
+
return json.dumps(result, indent=2)
|
|
238
|
+
|
|
239
|
+
wrapper.__name__ = func_name
|
|
240
|
+
wrapper.__doc__ = description
|
|
241
|
+
|
|
242
|
+
logger.info(f"Built resource tool: {func_name} (uri: {uri})")
|
|
243
|
+
|
|
244
|
+
return Tool(wrapper)
|
rem/agentic/otel/setup.py
CHANGED
|
@@ -14,6 +14,7 @@ from loguru import logger
|
|
|
14
14
|
|
|
15
15
|
from ...settings import settings
|
|
16
16
|
|
|
17
|
+
|
|
17
18
|
# Global flag to track if instrumentation is initialized
|
|
18
19
|
_instrumentation_initialized = False
|
|
19
20
|
|
|
@@ -52,12 +53,94 @@ def setup_instrumentation() -> None:
|
|
|
52
53
|
|
|
53
54
|
try:
|
|
54
55
|
from opentelemetry import trace
|
|
55
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
56
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
56
|
+
from opentelemetry.sdk.trace import TracerProvider, ReadableSpan
|
|
57
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
|
57
58
|
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, DEPLOYMENT_ENVIRONMENT
|
|
58
59
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPExporter
|
|
59
60
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCExporter
|
|
60
61
|
|
|
62
|
+
class SanitizingSpanExporter(SpanExporter):
|
|
63
|
+
"""
|
|
64
|
+
Wrapper exporter that sanitizes span attributes before export.
|
|
65
|
+
|
|
66
|
+
Removes None values that cause OTLP encoding failures like:
|
|
67
|
+
- llm.input_messages.3.message.content: None
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, wrapped_exporter: SpanExporter):
|
|
71
|
+
self._wrapped = wrapped_exporter
|
|
72
|
+
|
|
73
|
+
def _sanitize_value(self, value):
|
|
74
|
+
"""Recursively sanitize a value, replacing None with empty string."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return "" # Replace None with empty string
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
return {k: self._sanitize_value(v) for k, v in value.items()}
|
|
79
|
+
if isinstance(value, (list, tuple)):
|
|
80
|
+
return [self._sanitize_value(v) for v in value]
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
def export(self, spans: tuple[ReadableSpan, ...]) -> SpanExportResult:
|
|
84
|
+
# Create sanitized copies of spans
|
|
85
|
+
sanitized_spans = []
|
|
86
|
+
for span in spans:
|
|
87
|
+
if span.attributes:
|
|
88
|
+
# Sanitize all attribute values - replace None with empty string
|
|
89
|
+
sanitized_attrs = {}
|
|
90
|
+
for k, v in span.attributes.items():
|
|
91
|
+
sanitized_attrs[k] = self._sanitize_value(v)
|
|
92
|
+
sanitized_spans.append(_SanitizedSpan(span, sanitized_attrs))
|
|
93
|
+
else:
|
|
94
|
+
sanitized_spans.append(span)
|
|
95
|
+
|
|
96
|
+
return self._wrapped.export(tuple(sanitized_spans))
|
|
97
|
+
|
|
98
|
+
def shutdown(self) -> None:
|
|
99
|
+
self._wrapped.shutdown()
|
|
100
|
+
|
|
101
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
102
|
+
return self._wrapped.force_flush(timeout_millis)
|
|
103
|
+
|
|
104
|
+
class _SanitizedSpan(ReadableSpan):
|
|
105
|
+
"""ReadableSpan wrapper with sanitized attributes."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, original: ReadableSpan, sanitized_attributes: dict):
|
|
108
|
+
self._original = original
|
|
109
|
+
self._sanitized_attributes = sanitized_attributes
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def name(self): return self._original.name
|
|
113
|
+
@property
|
|
114
|
+
def context(self): return self._original.context
|
|
115
|
+
@property
|
|
116
|
+
def parent(self): return self._original.parent
|
|
117
|
+
@property
|
|
118
|
+
def resource(self): return self._original.resource
|
|
119
|
+
@property
|
|
120
|
+
def instrumentation_scope(self): return self._original.instrumentation_scope
|
|
121
|
+
@property
|
|
122
|
+
def status(self): return self._original.status
|
|
123
|
+
@property
|
|
124
|
+
def start_time(self): return self._original.start_time
|
|
125
|
+
@property
|
|
126
|
+
def end_time(self): return self._original.end_time
|
|
127
|
+
@property
|
|
128
|
+
def links(self): return self._original.links
|
|
129
|
+
@property
|
|
130
|
+
def events(self): return self._original.events
|
|
131
|
+
@property
|
|
132
|
+
def kind(self): return self._original.kind
|
|
133
|
+
@property
|
|
134
|
+
def attributes(self): return self._sanitized_attributes
|
|
135
|
+
@property
|
|
136
|
+
def dropped_attributes(self): return self._original.dropped_attributes
|
|
137
|
+
@property
|
|
138
|
+
def dropped_events(self): return self._original.dropped_events
|
|
139
|
+
@property
|
|
140
|
+
def dropped_links(self): return self._original.dropped_links
|
|
141
|
+
|
|
142
|
+
def get_span_context(self): return self._original.get_span_context()
|
|
143
|
+
|
|
61
144
|
# Create resource with service metadata
|
|
62
145
|
resource = Resource(
|
|
63
146
|
attributes={
|
|
@@ -72,16 +155,20 @@ def setup_instrumentation() -> None:
|
|
|
72
155
|
|
|
73
156
|
# Configure OTLP exporter based on protocol
|
|
74
157
|
if settings.otel.protocol == "grpc":
|
|
75
|
-
|
|
158
|
+
base_exporter = GRPCExporter(
|
|
76
159
|
endpoint=settings.otel.collector_endpoint,
|
|
77
160
|
timeout=settings.otel.export_timeout,
|
|
161
|
+
insecure=settings.otel.insecure,
|
|
78
162
|
)
|
|
79
163
|
else: # http
|
|
80
|
-
|
|
164
|
+
base_exporter = HTTPExporter(
|
|
81
165
|
endpoint=f"{settings.otel.collector_endpoint}/v1/traces",
|
|
82
166
|
timeout=settings.otel.export_timeout,
|
|
83
167
|
)
|
|
84
168
|
|
|
169
|
+
# Wrap with sanitizing exporter to handle None values
|
|
170
|
+
exporter = SanitizingSpanExporter(base_exporter)
|
|
171
|
+
|
|
85
172
|
# Add span processor
|
|
86
173
|
tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
87
174
|
|
|
@@ -95,6 +182,8 @@ def setup_instrumentation() -> None:
|
|
|
95
182
|
# Add OpenInference span processor for Pydantic AI
|
|
96
183
|
# This adds rich attributes (openinference.span.kind, input/output, etc.) to ALL traces
|
|
97
184
|
# Phoenix receives these traces via the OTLP collector - no separate "Phoenix integration" needed
|
|
185
|
+
# Note: The OTEL exporter may log warnings about None values in tool call messages,
|
|
186
|
+
# but this is a known limitation in openinference-instrumentation-pydantic-ai
|
|
98
187
|
try:
|
|
99
188
|
from openinference.instrumentation.pydantic_ai import OpenInferenceSpanProcessor as PydanticAISpanProcessor
|
|
100
189
|
|