remdb 0.3.226__py3-none-any.whl → 0.3.245__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/README.md CHANGED
@@ -718,263 +718,37 @@ See `rem/api/README.md` for full SSE event protocol documentation.
718
718
 
719
719
  ## Multi-Agent Orchestration
720
720
 
721
- REM supports hierarchical agent orchestration where agents can delegate work to other agents via the `ask_agent` tool. This enables complex workflows with specialized agents.
722
-
723
- ### Architecture
724
-
725
- ```mermaid
726
- sequenceDiagram
727
- participant User
728
- participant API as Chat API
729
- participant Orchestrator as Orchestrator Agent
730
- participant EventSink as Event Sink (Queue)
731
- participant Child as Child Agent
732
- participant DB as PostgreSQL
733
-
734
- User->>API: POST /chat/completions (stream=true)
735
- API->>API: Create event sink (asyncio.Queue)
736
- API->>Orchestrator: agent.iter(prompt)
737
-
738
- loop Streaming Loop
739
- Orchestrator->>API: PartDeltaEvent (text)
740
- API->>User: SSE: data: {"delta": {"content": "..."}}
741
- end
742
-
743
- Orchestrator->>Orchestrator: Decides to call ask_agent
744
- Orchestrator->>API: ToolCallPart (ask_agent)
745
- API->>User: SSE: event: tool_call
746
-
747
- API->>Child: ask_agent("child_name", input)
748
- Child->>EventSink: push_event(child_content)
749
- EventSink->>API: Consume child events
750
- API->>User: SSE: data: {"delta": {"content": "..."}}
751
-
752
- Child->>Child: Completes
753
- Child-->>Orchestrator: Return result
754
-
755
- Orchestrator->>API: Final response
756
- API->>DB: Save tool calls
757
- API->>DB: Save assistant message
758
- API->>User: SSE: data: [DONE]
759
- ```
760
-
761
- ### Event Sink Pattern
762
-
763
- When an agent delegates to a child via `ask_agent`, the child's streaming events need to bubble up to the parent's stream. This is achieved through an **event sink** pattern using Python's `ContextVar`:
764
-
765
- ```python
766
- # context.py
767
- from contextvars import ContextVar
768
-
769
- _parent_event_sink: ContextVar["asyncio.Queue | None"] = ContextVar(
770
- "parent_event_sink", default=None
771
- )
772
-
773
- async def push_event(event: Any) -> bool:
774
- """Push event to parent's event sink if available."""
775
- sink = _parent_event_sink.get()
776
- if sink is not None:
777
- await sink.put(event)
778
- return True
779
- return False
780
- ```
781
-
782
- The streaming controller sets up the event sink before agent execution:
783
-
784
- ```python
785
- # streaming.py
786
- child_event_sink: asyncio.Queue = asyncio.Queue()
787
- set_event_sink(child_event_sink)
788
-
789
- async for node in agent.iter(prompt):
790
- # Process agent events...
791
-
792
- # Consume any child events that arrived
793
- while not child_event_sink.empty():
794
- child_event = child_event_sink.get_nowait()
795
- if child_event["type"] == "child_content":
796
- yield format_sse_content_delta(child_event["content"])
797
- ```
798
-
799
- ### ask_agent Tool Implementation
800
-
801
- The `ask_agent` tool in `mcp_router/tools.py` uses Pydantic AI's streaming iteration:
802
-
803
- ```python
804
- async def ask_agent(agent_name: str, input_text: str, ...):
805
- """Delegate work to another agent."""
806
-
807
- # Load and create child agent
808
- schema = await load_agent_schema_async(agent_name, user_id)
809
- child_agent = await create_agent(context=context, agent_schema_override=schema)
810
-
811
- # Stream child agent with event proxying
812
- async with child_agent.iter(prompt) as agent_run:
813
- async for node in agent_run:
814
- if Agent.is_model_request_node(node):
815
- async with node.stream(agent_run.ctx) as request_stream:
816
- async for event in request_stream:
817
- if isinstance(event, PartDeltaEvent):
818
- # Push content to parent's event sink
819
- await push_event({
820
- "type": "child_content",
821
- "agent_name": agent_name,
822
- "content": event.delta.content_delta,
823
- })
824
-
825
- return agent_run.result
826
- ```
827
-
828
- ### Pydantic AI Features Used
721
+ Agents can delegate work to other agents via the `ask_agent` tool. This enables orchestrator patterns where a parent agent routes to specialists.
829
722
 
830
- #### 1. Streaming Iteration (`agent.iter()`)
723
+ ### How It Works
831
724
 
832
- Unlike `agent.run()` which blocks until completion, `agent.iter()` provides fine-grained control over the execution flow:
725
+ 1. **Parent agent** calls `ask_agent(agent_name, input_text)`
726
+ 2. **Child agent** executes and streams its response
727
+ 3. **Child events** bubble up to parent via an event sink (asyncio.Queue in ContextVar)
728
+ 4. **All tool calls** are saved to the database for the session
833
729
 
834
- ```python
835
- async with agent.iter(prompt) as agent_run:
836
- async for node in agent_run:
837
- if Agent.is_model_request_node(node):
838
- # Model is generating - stream the response
839
- async with node.stream(agent_run.ctx) as stream:
840
- async for event in stream:
841
- if isinstance(event, PartStartEvent):
842
- # Tool call starting
843
- elif isinstance(event, PartDeltaEvent):
844
- # Content chunk
845
- elif Agent.is_call_tools_node(node):
846
- # Tools are being executed
847
- async with node.stream(agent_run.ctx) as stream:
848
- async for event in stream:
849
- if isinstance(event, FunctionToolResultEvent):
850
- # Tool completed
851
- ```
852
-
853
- #### 2. Node Types
854
-
855
- - **`ModelRequestNode`**: The model is generating a response (text or tool calls)
856
- - **`CallToolsNode`**: Tools are being executed
857
- - **`End`**: Agent execution complete
858
-
859
- #### 3. Event Types
730
+ ### Key Components
860
731
 
861
- - **`PartStartEvent`**: A new part (text or tool call) is starting
862
- - **`PartDeltaEvent`**: Content chunk for streaming text
863
- - **`FunctionToolResultEvent`**: Tool execution completed with result
864
- - **`ToolCallPart`**: Metadata about a tool call (name, arguments)
865
- - **`TextPart`**: Text content
732
+ | Component | Location | Purpose |
733
+ |-----------|----------|---------|
734
+ | `ask_agent` tool | `mcp_router/tools.py` | Loads child agent, runs with streaming, pushes events to sink |
735
+ | Event sink | `context.py` | ContextVar holding asyncio.Queue for child→parent event flow |
736
+ | Streaming controller | `streaming.py` | Drains event sink, emits SSE events, saves to DB |
866
737
 
867
- ### Message Persistence
868
-
869
- All messages are persisted to PostgreSQL for session continuity:
870
-
871
- ```python
872
- # streaming.py - after agent completes
873
- async def save_session_messages(...):
874
- store = SessionMessageStore(user_id=user_id)
875
-
876
- # Save each tool call as a tool message
877
- for tool_call in tool_calls:
878
- await store.save_message(
879
- session_id=session_id,
880
- role="tool",
881
- content=tool_call.result,
882
- tool_name=tool_call.name,
883
- tool_call_id=tool_call.id,
884
- )
885
-
886
- # Save the final assistant response
887
- await store.save_message(
888
- session_id=session_id,
889
- role="assistant",
890
- content=accumulated_content,
891
- )
892
- ```
738
+ ### Event Types
893
739
 
894
- Messages are stored with:
895
- - **Embeddings**: For semantic search across conversation history
896
- - **Compression**: Long conversations are summarized to manage context window
897
- - **Session isolation**: Each session maintains its own message history
740
+ Child agents emit events that the parent streams to the client:
898
741
 
899
- ### Testing Multi-Agent Systems
742
+ - **`child_tool_start`**: Child is calling a tool (logged, streamed, saved to DB)
743
+ - **`child_content`**: Child's text response (streamed as SSE content delta)
744
+ - **`child_tool_result`**: Tool completed with result (metadata extraction)
900
745
 
901
- #### Integration Tests
746
+ ### Testing
902
747
 
903
- Real end-to-end tests without mocking are in `tests/integration/test_ask_agent_streaming.py`:
904
-
905
- ```python
906
- class TestAskAgentStreaming:
907
- async def test_ask_agent_streams_and_saves(self, session_id, user_id):
908
- """Test delegation via ask_agent."""
909
- # Uses test_orchestrator which always delegates to test_responder
910
- agent = await create_agent(context=context, agent_schema_override=schema)
911
-
912
- chunks = []
913
- async for chunk in stream_openai_response_with_save(
914
- agent=agent,
915
- prompt="Hello, please delegate this",
916
- ...
917
- ):
918
- chunks.append(chunk)
919
-
920
- # Verify streaming worked
921
- assert len(content_chunks) > 0
922
-
923
- # Verify persistence
924
- messages = await store.load_session_messages(session_id)
925
- assert len([m for m in messages if m["role"] == "assistant"]) == 1
926
- assert len([m for m in messages if m["tool_name"] == "ask_agent"]) >= 1
927
-
928
- async def test_multi_turn_saves_all_assistant_messages(self, session_id, user_id):
929
- """Test that each turn saves its own assistant message.
930
-
931
- This catches scoping bugs like accumulated_content not being
932
- properly scoped per-turn.
933
- """
934
- turn_prompts = [
935
- "Hello, how are you?",
936
- "Tell me something interesting",
937
- "Thanks for chatting!",
938
- ]
939
-
940
- for prompt in turn_prompts:
941
- async for chunk in stream_openai_response_with_save(...):
942
- pass
943
-
944
- # Each turn should save an assistant message
945
- messages = await store.load_session_messages(session_id)
946
- assistant_msgs = [m for m in messages if m["role"] == "assistant"]
947
- assert len(assistant_msgs) == 3
948
- ```
949
-
950
- #### Test Agent Schemas
951
-
952
- Test agents are defined in `tests/data/schemas/agents/`:
953
-
954
- - **`test_orchestrator.yaml`**: Always delegates via `ask_agent`
955
- - **`test_responder.yaml`**: Simple agent that responds directly
956
-
957
- ```yaml
958
- # test_orchestrator.yaml
959
- type: object
960
- description: |
961
- You are a TEST ORCHESTRATOR that ALWAYS delegates to another agent.
962
- Call ask_agent with agent_name="test_responder" on EVERY turn.
963
- json_schema_extra:
964
- kind: agent
965
- name: test_orchestrator
966
- tools:
967
- - name: ask_agent
968
- mcp_server: rem
969
- ```
970
-
971
- #### Running Integration Tests
972
-
973
- ```bash
974
- # Run individually (recommended due to async isolation)
975
- POSTGRES__CONNECTION_STRING="postgresql://rem:rem@localhost:5050/rem" \
976
- uv run pytest tests/integration/test_ask_agent_streaming.py::TestAskAgentStreaming::test_multi_turn_saves_all_assistant_messages -v -s
977
- ```
748
+ Integration tests in `tests/integration/test_ask_agent_streaming.py` verify:
749
+ - Child content streams correctly to client
750
+ - Tool calls are persisted to database
751
+ - Multi-turn conversations save all messages
978
752
 
979
753
  ## Future Work
980
754
 
rem/agentic/context.py CHANGED
@@ -16,6 +16,7 @@ Headers Mapping:
16
16
  X-Agent-Schema → context.agent_schema_uri (default: "rem")
17
17
  X-Model-Name → context.default_model
18
18
  X-Is-Eval → context.is_eval (marks session as evaluation)
19
+ X-Client-Id → context.client_id (e.g., "web", "mobile", "cli")
19
20
 
20
21
  Key Design Pattern:
21
22
  - AgentContext is passed to agent factory, not stored in agents
@@ -222,6 +223,11 @@ class AgentContext(BaseModel):
222
223
  description="Whether this is an evaluation session (set via X-Is-Eval header)",
223
224
  )
224
225
 
226
+ client_id: str | None = Field(
227
+ default=None,
228
+ description="Client identifier (e.g., 'web', 'mobile', 'cli') set via X-Client-Id header",
229
+ )
230
+
225
231
  model_config = {"populate_by_name": True}
226
232
 
227
233
  def child_context(
@@ -232,7 +238,7 @@ class AgentContext(BaseModel):
232
238
  """
233
239
  Create a child context for nested agent calls.
234
240
 
235
- Inherits user_id, tenant_id, session_id, is_eval from parent.
241
+ Inherits user_id, tenant_id, session_id, is_eval, client_id from parent.
236
242
  Allows overriding agent_schema_uri and default_model for the child.
237
243
 
238
244
  Args:
@@ -256,6 +262,7 @@ class AgentContext(BaseModel):
256
262
  default_model=model_override or self.default_model,
257
263
  agent_schema_uri=agent_schema_uri or self.agent_schema_uri,
258
264
  is_eval=self.is_eval,
265
+ client_id=self.client_id,
259
266
  )
260
267
 
261
268
  @staticmethod
@@ -374,6 +381,7 @@ class AgentContext(BaseModel):
374
381
  default_model=normalized.get("x-model-name") or settings.llm.default_model,
375
382
  agent_schema_uri=normalized.get("x-agent-schema"),
376
383
  is_eval=is_eval,
384
+ client_id=normalized.get("x-client-id"),
377
385
  )
378
386
 
379
387
  @classmethod
@@ -391,6 +399,7 @@ class AgentContext(BaseModel):
391
399
  - X-Model-Name: Model override
392
400
  - X-Agent-Schema: Agent schema URI
393
401
  - X-Is-Eval: Whether this is an evaluation session (true/false)
402
+ - X-Client-Id: Client identifier (e.g., "web", "mobile", "cli")
394
403
 
395
404
  Args:
396
405
  headers: Dictionary of HTTP headers (case-insensitive)
@@ -404,7 +413,8 @@ class AgentContext(BaseModel):
404
413
  "X-Tenant-Id": "acme-corp",
405
414
  "X-Session-Id": "sess-456",
406
415
  "X-Model-Name": "anthropic:claude-opus-4-20250514",
407
- "X-Is-Eval": "true"
416
+ "X-Is-Eval": "true",
417
+ "X-Client-Id": "web"
408
418
  }
409
419
  context = AgentContext.from_headers(headers)
410
420
  """
@@ -422,4 +432,5 @@ class AgentContext(BaseModel):
422
432
  default_model=normalized.get("x-model-name") or settings.llm.default_model,
423
433
  agent_schema_uri=normalized.get("x-agent-schema"),
424
434
  is_eval=is_eval,
435
+ client_id=normalized.get("x-client-id"),
425
436
  )
@@ -4,15 +4,12 @@ Centralized context builder for agent execution.
4
4
  Session History (ALWAYS loaded with compression):
5
5
  - Each chat request is a single message, so session history MUST be recovered
6
6
  - Uses SessionMessageStore with compression to keep context efficient
7
- - Long assistant responses include REM LOOKUP hints: "... [REM LOOKUP session-{id}-msg-{index}] ..."
8
- - Agent can retrieve full content on-demand using REM LOOKUP
9
7
  - Prevents context window bloat while maintaining conversation continuity
10
8
 
11
9
  User Context (on-demand by default):
12
- - System message includes REM LOOKUP hint for user profile
13
- - Agent decides whether to load profile based on query
14
- - More efficient for queries that don't need personalization
15
- - Example: "User: sarah@example.com. To load user profile: Use REM LOOKUP \"sarah@example.com\""
10
+ - System message includes user email for context awareness
11
+ - Fails silently if user not found - agent proceeds without user context
12
+ - Example: "User: sarah@example.com"
16
13
 
17
14
  User Context (auto-inject when enabled):
18
15
  - Set CHAT__AUTO_INJECT_USER_CONTEXT=true
@@ -22,8 +19,8 @@ User Context (auto-inject when enabled):
22
19
  Design Pattern:
23
20
  1. Extract AgentContext from headers (user_id, tenant_id, session_id)
24
21
  2. If auto-inject enabled: Load User/Session from database
25
- 3. If auto-inject disabled: Provide REM LOOKUP hints in system message
26
- 4. Construct system message with date + context (injected or hints)
22
+ 3. If auto-inject disabled: Show user email for context (fail silently if not found)
23
+ 4. Construct system message with date + context
27
24
  5. Return complete context ready for agent execution
28
25
 
29
26
  Integration Points:
@@ -40,11 +37,10 @@ Usage (on-demand, default):
40
37
 
41
38
  # Messages list structure (on-demand):
42
39
  # [
43
- # {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\"\nSession ID: sess-123\nTo load session history: Use REM LOOKUP messages?session_id=sess-123"},
40
+ # {"role": "system", "content": "Today's date: 2025-11-22\n\nUser: sarah@example.com"},
44
41
  # {"role": "user", "content": "What's next for the API migration?"}
45
42
  # ]
46
43
 
47
- # Agent receives hints and can decide to load context if needed
48
44
  agent = await create_agent(context=context, ...)
49
45
  prompt = "\n".join(msg.content for msg in messages)
50
46
  result = await agent.run(prompt)
@@ -52,7 +48,7 @@ Usage (on-demand, default):
52
48
  Usage (auto-inject, CHAT__AUTO_INJECT_USER_CONTEXT=true):
53
49
  # Messages list structure (auto-inject):
54
50
  # [
55
- # {"role": "system", "content": "Today's date: 2025-11-22\n\nUser Context (auto-injected):\nSummary: ...\nInterests: ...\n\nSession History (auto-injected, 5 messages):"},
51
+ # {"role": "system", "content": "Today's date: 2025-11-22\n\nUser Context (auto-injected):\nSummary: ...\nInterests: ..."},
56
52
  # {"role": "user", "content": "Previous message"},
57
53
  # {"role": "assistant", "content": "Previous response"},
58
54
  # {"role": "user", "content": "What's next for the API migration?"}
@@ -110,13 +106,11 @@ class ContextBuilder:
110
106
 
111
107
  Session History (ALWAYS loaded with compression):
112
108
  - If session_id provided, session history is ALWAYS loaded using SessionMessageStore
113
- - Compression keeps it efficient with REM LOOKUP hints for long messages
114
- - Example: "... [Message truncated - REM LOOKUP session-{id}-msg-{index}] ..."
115
- - Agent can retrieve full content on-demand using REM LOOKUP
109
+ - Compression keeps context efficient
116
110
 
117
111
  User Context (on-demand by default):
118
- - System message includes REM LOOKUP hint: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
119
- - Agent decides whether to load profile based on query
112
+ - System message includes user email: "User: {email}"
113
+ - Fails silently if user not found - agent proceeds without user context
120
114
 
121
115
  User Context (auto-inject when enabled):
122
116
  - Set CHAT__AUTO_INJECT_USER_CONTEXT=true
@@ -137,9 +131,9 @@ class ContextBuilder:
137
131
 
138
132
  # messages structure:
139
133
  # [
140
- # {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\""},
134
+ # {"role": "system", "content": "Today's date: 2025-11-22\n\nUser: sarah@example.com"},
141
135
  # {"role": "user", "content": "Previous message"},
142
- # {"role": "assistant", "content": "Start of long response... [REM LOOKUP session-123-msg-1] ...end"},
136
+ # {"role": "assistant", "content": "Previous response"},
143
137
  # {"role": "user", "content": "New message"}
144
138
  # ]
145
139
  """
@@ -158,6 +152,7 @@ class ContextBuilder:
158
152
  default_model=context.default_model,
159
153
  agent_schema_uri=context.agent_schema_uri,
160
154
  is_eval=context.is_eval,
155
+ client_id=context.client_id,
161
156
  )
162
157
 
163
158
  # Initialize DB if not provided and needed (for user context or session history)
@@ -177,6 +172,10 @@ class ContextBuilder:
177
172
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
178
173
  context_hint = f"Today's date: {today}."
179
174
 
175
+ # Add client identifier if present
176
+ if context.client_id:
177
+ context_hint += f"\nClient: {context.client_id}"
178
+
180
179
  # Add user context (auto-inject or on-demand hint)
181
180
  if settings.chat.auto_inject_user_context and context.user_id and db:
182
181
  # Auto-inject: Load and include user profile
@@ -189,18 +188,18 @@ class ContextBuilder:
189
188
  context_hint += f"\n\nUser Context (auto-injected):\n{user_context_content}"
190
189
  else:
191
190
  context_hint += "\n\nNo user context available (anonymous or new user)."
192
- elif context.user_id:
193
- # On-demand: Provide hint to use REM LOOKUP
194
- # user_id is UUID5 hash of email - load user to get email for display and LOOKUP
195
- user_repo = Repository(User, "users", db=db)
196
- user = await user_repo.get_by_id(context.user_id, context.tenant_id)
197
- if user and user.email:
198
- # Show email (more useful than UUID) and LOOKUP hint
199
- context_hint += f"\n\nUser: {user.email}"
200
- context_hint += f"\nTo load user profile: Use REM LOOKUP \"{user.email}\""
201
- else:
202
- context_hint += f"\n\nUser ID: {context.user_id}"
203
- context_hint += "\nUser profile not available."
191
+ elif context.user_id and db:
192
+ # On-demand: Show user email for context (no REM LOOKUP - it requires exact user_id match)
193
+ # Fail silently if user lookup fails - just proceed without user context
194
+ try:
195
+ user_repo = Repository(User, "users", db=db)
196
+ user = await user_repo.get_by_id(context.user_id, context.tenant_id)
197
+ if user and user.email:
198
+ context_hint += f"\n\nUser: {user.email}"
199
+ # If user not found, just proceed without adding user context
200
+ except Exception as e:
201
+ # Fail silently - don't block agent execution if user lookup fails
202
+ logger.debug(f"Could not load user context: {e}")
204
203
 
205
204
  # Add system context hint
206
205
  messages.append(ContextMessage(role="system", content=context_hint))
@@ -318,6 +317,7 @@ class ContextBuilder:
318
317
  session_id: str | None = None,
319
318
  message: str = "Hello",
320
319
  model: str | None = None,
320
+ client_id: str | None = None,
321
321
  ) -> tuple[AgentContext, list[ContextMessage]]:
322
322
  """
323
323
  Build context for testing (no database lookup).
@@ -325,7 +325,7 @@ class ContextBuilder:
325
325
  Creates minimal context with:
326
326
  - Test user (test@rem.ai)
327
327
  - Test tenant
328
- - Context hint with date
328
+ - Context hint with date and client
329
329
  - Single user message
330
330
 
331
331
  Args:
@@ -334,6 +334,7 @@ class ContextBuilder:
334
334
  session_id: Optional session ID
335
335
  message: User message content
336
336
  model: Optional model override
337
+ client_id: Optional client identifier (e.g., "cli", "test")
337
338
 
338
339
  Returns:
339
340
  Tuple of (AgentContext, messages list)
@@ -341,7 +342,8 @@ class ContextBuilder:
341
342
  Example:
342
343
  context, messages = await ContextBuilder.build_from_test(
343
344
  user_id="test@rem.ai",
344
- message="What's the weather like?"
345
+ message="What's the weather like?",
346
+ client_id="cli"
345
347
  )
346
348
  """
347
349
  from ..settings import settings
@@ -352,11 +354,15 @@ class ContextBuilder:
352
354
  tenant_id=tenant_id,
353
355
  session_id=session_id,
354
356
  default_model=model or settings.llm.default_model,
357
+ client_id=client_id,
355
358
  )
356
359
 
357
360
  # Build minimal messages
358
361
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
359
- context_hint = f"Today's date: {today}.\n\nTest user context: {user_id} (test mode, no profile loaded)."
362
+ context_hint = f"Today's date: {today}."
363
+ if client_id:
364
+ context_hint += f"\nClient: {client_id}"
365
+ context_hint += f"\n\nTest user context: {user_id} (test mode, no profile loaded)."
360
366
 
361
367
  messages = [
362
368
  ContextMessage(role="system", content=context_hint),