remdb 0.3.200__py3-none-any.whl → 0.3.226__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 +262 -2
- rem/agentic/context.py +73 -1
- rem/agentic/mcp/tool_wrapper.py +2 -2
- rem/agentic/providers/pydantic_ai.py +1 -1
- rem/agentic/schema.py +2 -2
- rem/api/mcp_router/tools.py +154 -18
- rem/api/routers/admin.py +30 -4
- rem/api/routers/auth.py +106 -10
- rem/api/routers/chat/completions.py +24 -29
- rem/api/routers/chat/sse_events.py +5 -1
- rem/api/routers/chat/streaming.py +163 -2
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +7 -1
- rem/api/routers/feedback.py +9 -1
- rem/api/routers/messages.py +80 -15
- rem/api/routers/models.py +9 -1
- rem/api/routers/query.py +12 -1
- rem/api/routers/shared_sessions.py +16 -0
- rem/auth/jwt.py +19 -4
- rem/cli/commands/ask.py +61 -81
- rem/cli/commands/process.py +3 -3
- rem/models/entities/ontology.py +18 -20
- rem/schemas/agents/rem.yaml +1 -1
- rem/services/postgres/repository.py +14 -4
- rem/services/session/__init__.py +2 -1
- rem/services/session/compression.py +40 -2
- rem/services/session/pydantic_messages.py +66 -0
- rem/settings.py +28 -0
- rem/sql/migrations/001_install.sql +13 -3
- rem/sql/migrations/002_install_models.sql +20 -22
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/schema_loader.py +73 -45
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/METADATA +1 -1
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/RECORD +36 -34
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/WHEEL +0 -0
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/entry_points.txt +0 -0
rem/agentic/README.md
CHANGED
|
@@ -716,11 +716,271 @@ curl -X POST http://localhost:8000/api/v1/chat/completions \
|
|
|
716
716
|
|
|
717
717
|
See `rem/api/README.md` for full SSE event protocol documentation.
|
|
718
718
|
|
|
719
|
+
## Multi-Agent Orchestration
|
|
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
|
|
829
|
+
|
|
830
|
+
#### 1. Streaming Iteration (`agent.iter()`)
|
|
831
|
+
|
|
832
|
+
Unlike `agent.run()` which blocks until completion, `agent.iter()` provides fine-grained control over the execution flow:
|
|
833
|
+
|
|
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
|
|
860
|
+
|
|
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
|
|
866
|
+
|
|
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
|
+
```
|
|
893
|
+
|
|
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
|
|
898
|
+
|
|
899
|
+
### Testing Multi-Agent Systems
|
|
900
|
+
|
|
901
|
+
#### Integration Tests
|
|
902
|
+
|
|
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
|
+
```
|
|
978
|
+
|
|
719
979
|
## Future Work
|
|
720
980
|
|
|
721
981
|
- [ ] Phoenix evaluator integration
|
|
722
982
|
- [ ] Agent schema registry (load schemas by URI)
|
|
723
983
|
- [ ] Schema validation and versioning
|
|
724
|
-
- [
|
|
725
|
-
- [
|
|
984
|
+
- [x] Multi-turn conversation management
|
|
985
|
+
- [x] Agent composition (agents calling agents)
|
|
726
986
|
- [ ] Alternative provider implementations (if needed)
|
rem/agentic/context.py
CHANGED
|
@@ -30,9 +30,10 @@ Multi-Agent Context Propagation:
|
|
|
30
30
|
- Child agents inherit user_id, tenant_id, session_id, is_eval from parent
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
+
import asyncio
|
|
33
34
|
from contextlib import contextmanager
|
|
34
35
|
from contextvars import ContextVar
|
|
35
|
-
from typing import Generator
|
|
36
|
+
from typing import Any, Generator
|
|
36
37
|
|
|
37
38
|
from loguru import logger
|
|
38
39
|
from pydantic import BaseModel, Field
|
|
@@ -46,6 +47,13 @@ _current_agent_context: ContextVar["AgentContext | None"] = ContextVar(
|
|
|
46
47
|
"current_agent_context", default=None
|
|
47
48
|
)
|
|
48
49
|
|
|
50
|
+
# Event sink for streaming child agent events to parent
|
|
51
|
+
# When set, child agents (via ask_agent) should push their events here
|
|
52
|
+
# for the parent's streaming loop to proxy to the client
|
|
53
|
+
_parent_event_sink: ContextVar["asyncio.Queue | None"] = ContextVar(
|
|
54
|
+
"parent_event_sink", default=None
|
|
55
|
+
)
|
|
56
|
+
|
|
49
57
|
|
|
50
58
|
def get_current_context() -> "AgentContext | None":
|
|
51
59
|
"""
|
|
@@ -97,6 +105,70 @@ def agent_context_scope(ctx: "AgentContext") -> Generator["AgentContext", None,
|
|
|
97
105
|
_current_agent_context.set(previous)
|
|
98
106
|
|
|
99
107
|
|
|
108
|
+
# =============================================================================
|
|
109
|
+
# Event Sink for Streaming Multi-Agent Delegation
|
|
110
|
+
# =============================================================================
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_event_sink() -> "asyncio.Queue | None":
|
|
114
|
+
"""
|
|
115
|
+
Get the parent's event sink for streaming child events.
|
|
116
|
+
|
|
117
|
+
Used by ask_agent to push child agent events to the parent's stream.
|
|
118
|
+
Returns None if not in a streaming context.
|
|
119
|
+
"""
|
|
120
|
+
return _parent_event_sink.get()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def set_event_sink(sink: "asyncio.Queue | None") -> None:
|
|
124
|
+
"""Set the event sink for child agents to push events to."""
|
|
125
|
+
_parent_event_sink.set(sink)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def event_sink_scope(sink: "asyncio.Queue") -> Generator["asyncio.Queue", None, None]:
|
|
130
|
+
"""
|
|
131
|
+
Context manager for scoped event sink setting.
|
|
132
|
+
|
|
133
|
+
Used by streaming layer to set up event proxying before tool execution.
|
|
134
|
+
Child agents (via ask_agent) will push their events to this sink.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
event_queue = asyncio.Queue()
|
|
138
|
+
with event_sink_scope(event_queue):
|
|
139
|
+
# ask_agent will push child events to event_queue
|
|
140
|
+
async for event in tools_stream:
|
|
141
|
+
...
|
|
142
|
+
# Also consume from event_queue
|
|
143
|
+
"""
|
|
144
|
+
previous = _parent_event_sink.get()
|
|
145
|
+
_parent_event_sink.set(sink)
|
|
146
|
+
try:
|
|
147
|
+
yield sink
|
|
148
|
+
finally:
|
|
149
|
+
_parent_event_sink.set(previous)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def push_event(event: Any) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Push an event to the parent's event sink (if available).
|
|
155
|
+
|
|
156
|
+
Used by ask_agent to proxy child agent events to the parent's stream.
|
|
157
|
+
Returns True if event was pushed, False if no sink available.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
event: Any streaming event (ToolCallEvent, content chunk, etc.)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if event was pushed to sink, False otherwise
|
|
164
|
+
"""
|
|
165
|
+
sink = _parent_event_sink.get()
|
|
166
|
+
if sink is not None:
|
|
167
|
+
await sink.put(event)
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
100
172
|
class AgentContext(BaseModel):
|
|
101
173
|
"""
|
|
102
174
|
Session and configuration context for agent execution.
|
rem/agentic/mcp/tool_wrapper.py
CHANGED
|
@@ -116,7 +116,7 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
116
116
|
the artificial MCP distinction between tools and resources.
|
|
117
117
|
|
|
118
118
|
Supports both:
|
|
119
|
-
- Concrete URIs: "rem://
|
|
119
|
+
- Concrete URIs: "rem://agents" -> tool with no parameters
|
|
120
120
|
- Template URIs: "patient-profile://field/{field_key}" -> tool with field_key parameter
|
|
121
121
|
|
|
122
122
|
Args:
|
|
@@ -131,7 +131,7 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
131
131
|
|
|
132
132
|
Example:
|
|
133
133
|
# Concrete URI -> no-param tool
|
|
134
|
-
tool = create_resource_tool("rem://
|
|
134
|
+
tool = create_resource_tool("rem://agents", "List all agent schemas")
|
|
135
135
|
|
|
136
136
|
# Template URI -> parameterized tool
|
|
137
137
|
tool = create_resource_tool("patient-profile://field/{field_key}", "Get field definition", mcp_server=mcp)
|
|
@@ -732,7 +732,7 @@ async def create_agent(
|
|
|
732
732
|
# the artificial MCP distinction between tools and resources
|
|
733
733
|
#
|
|
734
734
|
# Supports both concrete and template URIs:
|
|
735
|
-
# - Concrete: "rem://
|
|
735
|
+
# - Concrete: "rem://agents" -> no-param tool
|
|
736
736
|
# - Template: "patient-profile://field/{field_key}" -> tool with field_key param
|
|
737
737
|
from ..mcp.tool_wrapper import create_resource_tool
|
|
738
738
|
|
rem/agentic/schema.py
CHANGED
|
@@ -79,7 +79,7 @@ class MCPResourceReference(BaseModel):
|
|
|
79
79
|
|
|
80
80
|
Example (exact URI):
|
|
81
81
|
{
|
|
82
|
-
"uri": "rem://
|
|
82
|
+
"uri": "rem://agents",
|
|
83
83
|
"name": "Agent Schemas",
|
|
84
84
|
"description": "List all available agent schemas"
|
|
85
85
|
}
|
|
@@ -96,7 +96,7 @@ class MCPResourceReference(BaseModel):
|
|
|
96
96
|
default=None,
|
|
97
97
|
description=(
|
|
98
98
|
"Exact resource URI or URI with query parameters. "
|
|
99
|
-
"Examples: 'rem://
|
|
99
|
+
"Examples: 'rem://agents', 'rem://resources?category=drug.*'"
|
|
100
100
|
)
|
|
101
101
|
)
|
|
102
102
|
|
rem/api/mcp_router/tools.py
CHANGED
|
@@ -594,15 +594,18 @@ async def read_resource(uri: str) -> dict[str, Any]:
|
|
|
594
594
|
**Available Resources:**
|
|
595
595
|
|
|
596
596
|
Agent Schemas:
|
|
597
|
-
• rem://
|
|
598
|
-
• rem://
|
|
599
|
-
|
|
597
|
+
• rem://agents - List all available agent schemas
|
|
598
|
+
• rem://agents/{agent_name} - Get specific agent schema
|
|
599
|
+
|
|
600
|
+
Documentation:
|
|
601
|
+
• rem://schema/entities - Entity schemas (Resource, Message, User, File, Moment)
|
|
602
|
+
• rem://schema/query-types - REM query type documentation
|
|
600
603
|
|
|
601
604
|
System Status:
|
|
602
605
|
• rem://status - System health and statistics
|
|
603
606
|
|
|
604
607
|
Args:
|
|
605
|
-
uri: Resource URI (e.g., "rem://
|
|
608
|
+
uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem")
|
|
606
609
|
|
|
607
610
|
Returns:
|
|
608
611
|
Dict with:
|
|
@@ -611,14 +614,11 @@ async def read_resource(uri: str) -> dict[str, Any]:
|
|
|
611
614
|
- data: Resource data (format depends on resource type)
|
|
612
615
|
|
|
613
616
|
Examples:
|
|
614
|
-
# List all
|
|
615
|
-
read_resource(uri="rem://
|
|
616
|
-
|
|
617
|
-
# Get specific schema
|
|
618
|
-
read_resource(uri="rem://schema/ask_rem")
|
|
617
|
+
# List all agents
|
|
618
|
+
read_resource(uri="rem://agents")
|
|
619
619
|
|
|
620
|
-
# Get
|
|
621
|
-
read_resource(uri="rem://
|
|
620
|
+
# Get specific agent
|
|
621
|
+
read_resource(uri="rem://agents/ask_rem")
|
|
622
622
|
|
|
623
623
|
# Check system status
|
|
624
624
|
read_resource(uri="rem://status")
|
|
@@ -1265,7 +1265,7 @@ async def ask_agent(
|
|
|
1265
1265
|
"""
|
|
1266
1266
|
import asyncio
|
|
1267
1267
|
from ...agentic import create_agent
|
|
1268
|
-
from ...agentic.context import get_current_context, agent_context_scope
|
|
1268
|
+
from ...agentic.context import get_current_context, agent_context_scope, get_event_sink, push_event
|
|
1269
1269
|
from ...agentic.agents.agent_manager import get_agent
|
|
1270
1270
|
from ...utils.schema_loader import load_agent_schema
|
|
1271
1271
|
|
|
@@ -1342,16 +1342,146 @@ async def ask_agent(
|
|
|
1342
1342
|
if input_data:
|
|
1343
1343
|
prompt = f"{input_text}\n\nInput data: {json.dumps(input_data)}"
|
|
1344
1344
|
|
|
1345
|
+
# Load session history for the sub-agent (CRITICAL for multi-turn conversations)
|
|
1346
|
+
# Sub-agents need to see the full conversation context, not just the summary
|
|
1347
|
+
pydantic_message_history = None
|
|
1348
|
+
if child_context.session_id and settings.postgres.enabled:
|
|
1349
|
+
try:
|
|
1350
|
+
from ...services.session import SessionMessageStore, session_to_pydantic_messages
|
|
1351
|
+
from ...agentic.schema import get_system_prompt
|
|
1352
|
+
|
|
1353
|
+
store = SessionMessageStore(user_id=child_context.user_id or "default")
|
|
1354
|
+
raw_session_history = await store.load_session_messages(
|
|
1355
|
+
session_id=child_context.session_id,
|
|
1356
|
+
user_id=child_context.user_id,
|
|
1357
|
+
compress_on_load=False, # Need full data for reconstruction
|
|
1358
|
+
)
|
|
1359
|
+
if raw_session_history:
|
|
1360
|
+
# Extract agent's system prompt from schema
|
|
1361
|
+
agent_system_prompt = get_system_prompt(schema) if schema else None
|
|
1362
|
+
pydantic_message_history = session_to_pydantic_messages(
|
|
1363
|
+
raw_session_history,
|
|
1364
|
+
system_prompt=agent_system_prompt,
|
|
1365
|
+
)
|
|
1366
|
+
logger.debug(
|
|
1367
|
+
f"ask_agent '{agent_name}': loaded {len(raw_session_history)} session messages "
|
|
1368
|
+
f"-> {len(pydantic_message_history)} pydantic-ai messages"
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
# Audit session history if enabled
|
|
1372
|
+
from ...services.session import audit_session_history
|
|
1373
|
+
audit_session_history(
|
|
1374
|
+
session_id=child_context.session_id,
|
|
1375
|
+
agent_name=agent_name,
|
|
1376
|
+
prompt=prompt,
|
|
1377
|
+
raw_session_history=raw_session_history,
|
|
1378
|
+
pydantic_messages_count=len(pydantic_message_history),
|
|
1379
|
+
)
|
|
1380
|
+
except Exception as e:
|
|
1381
|
+
logger.warning(f"ask_agent '{agent_name}': failed to load session history: {e}")
|
|
1382
|
+
# Fall back to running without history
|
|
1383
|
+
|
|
1345
1384
|
# Run agent with timeout and context propagation
|
|
1346
1385
|
logger.info(f"Invoking agent '{agent_name}' with prompt: {prompt[:100]}...")
|
|
1347
1386
|
|
|
1387
|
+
# Check if we have an event sink for streaming
|
|
1388
|
+
push_event = get_event_sink()
|
|
1389
|
+
use_streaming = push_event is not None
|
|
1390
|
+
|
|
1391
|
+
streamed_content = "" # Track if content was streamed
|
|
1392
|
+
|
|
1348
1393
|
try:
|
|
1349
1394
|
# Set child context for nested tool calls
|
|
1350
1395
|
with agent_context_scope(child_context):
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1396
|
+
if use_streaming:
|
|
1397
|
+
# STREAMING MODE: Use iter() and proxy events to parent
|
|
1398
|
+
logger.debug(f"ask_agent '{agent_name}': using streaming mode with event proxying")
|
|
1399
|
+
|
|
1400
|
+
async def run_with_streaming():
|
|
1401
|
+
from pydantic_ai.messages import (
|
|
1402
|
+
PartStartEvent, PartDeltaEvent, PartEndEvent,
|
|
1403
|
+
FunctionToolResultEvent, FunctionToolCallEvent,
|
|
1404
|
+
)
|
|
1405
|
+
from pydantic_ai.agent import Agent
|
|
1406
|
+
|
|
1407
|
+
accumulated_content = []
|
|
1408
|
+
child_tool_calls = []
|
|
1409
|
+
|
|
1410
|
+
# iter() returns an async context manager, not an awaitable
|
|
1411
|
+
iter_kwargs = {"message_history": pydantic_message_history} if pydantic_message_history else {}
|
|
1412
|
+
async with agent_runtime.iter(prompt, **iter_kwargs) as agent_run:
|
|
1413
|
+
async for node in agent_run:
|
|
1414
|
+
if Agent.is_model_request_node(node):
|
|
1415
|
+
async with node.stream(agent_run.ctx) as request_stream:
|
|
1416
|
+
async for event in request_stream:
|
|
1417
|
+
# Proxy part starts
|
|
1418
|
+
if isinstance(event, PartStartEvent):
|
|
1419
|
+
from pydantic_ai.messages import ToolCallPart, TextPart
|
|
1420
|
+
if isinstance(event.part, ToolCallPart):
|
|
1421
|
+
# Push tool start event to parent
|
|
1422
|
+
await push_event.put({
|
|
1423
|
+
"type": "child_tool_start",
|
|
1424
|
+
"agent_name": agent_name,
|
|
1425
|
+
"tool_name": event.part.tool_name,
|
|
1426
|
+
"arguments": event.part.args if hasattr(event.part, 'args') else None,
|
|
1427
|
+
})
|
|
1428
|
+
child_tool_calls.append({
|
|
1429
|
+
"tool_name": event.part.tool_name,
|
|
1430
|
+
"index": event.index,
|
|
1431
|
+
})
|
|
1432
|
+
elif isinstance(event.part, TextPart):
|
|
1433
|
+
# TextPart may have initial content
|
|
1434
|
+
if event.part.content:
|
|
1435
|
+
accumulated_content.append(event.part.content)
|
|
1436
|
+
await push_event.put({
|
|
1437
|
+
"type": "child_content",
|
|
1438
|
+
"agent_name": agent_name,
|
|
1439
|
+
"content": event.part.content,
|
|
1440
|
+
})
|
|
1441
|
+
# Proxy text content deltas to parent for real-time streaming
|
|
1442
|
+
elif isinstance(event, PartDeltaEvent):
|
|
1443
|
+
if hasattr(event, 'delta') and hasattr(event.delta, 'content_delta'):
|
|
1444
|
+
content = event.delta.content_delta
|
|
1445
|
+
if content:
|
|
1446
|
+
accumulated_content.append(content)
|
|
1447
|
+
# Push content chunk to parent for streaming
|
|
1448
|
+
await push_event.put({
|
|
1449
|
+
"type": "child_content",
|
|
1450
|
+
"agent_name": agent_name,
|
|
1451
|
+
"content": content,
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
elif Agent.is_call_tools_node(node):
|
|
1455
|
+
async with node.stream(agent_run.ctx) as tools_stream:
|
|
1456
|
+
async for tool_event in tools_stream:
|
|
1457
|
+
if isinstance(tool_event, FunctionToolResultEvent):
|
|
1458
|
+
result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
|
|
1459
|
+
# Push tool result to parent
|
|
1460
|
+
await push_event.put({
|
|
1461
|
+
"type": "child_tool_result",
|
|
1462
|
+
"agent_name": agent_name,
|
|
1463
|
+
"result": result_content,
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
# Get final result (inside context manager)
|
|
1467
|
+
return agent_run.result, "".join(accumulated_content), child_tool_calls
|
|
1468
|
+
|
|
1469
|
+
result, streamed_content, tool_calls = await asyncio.wait_for(
|
|
1470
|
+
run_with_streaming(),
|
|
1471
|
+
timeout=timeout_seconds
|
|
1472
|
+
)
|
|
1473
|
+
else:
|
|
1474
|
+
# NON-STREAMING MODE: Use run() for backwards compatibility
|
|
1475
|
+
if pydantic_message_history:
|
|
1476
|
+
result = await asyncio.wait_for(
|
|
1477
|
+
agent_runtime.run(prompt, message_history=pydantic_message_history),
|
|
1478
|
+
timeout=timeout_seconds
|
|
1479
|
+
)
|
|
1480
|
+
else:
|
|
1481
|
+
result = await asyncio.wait_for(
|
|
1482
|
+
agent_runtime.run(prompt),
|
|
1483
|
+
timeout=timeout_seconds
|
|
1484
|
+
)
|
|
1355
1485
|
except asyncio.TimeoutError:
|
|
1356
1486
|
return {
|
|
1357
1487
|
"status": "error",
|
|
@@ -1365,14 +1495,20 @@ async def ask_agent(
|
|
|
1365
1495
|
|
|
1366
1496
|
logger.info(f"Agent '{agent_name}' completed successfully")
|
|
1367
1497
|
|
|
1368
|
-
|
|
1498
|
+
response = {
|
|
1369
1499
|
"status": "success",
|
|
1370
1500
|
"output": output,
|
|
1371
|
-
"text_response": str(result.output),
|
|
1372
1501
|
"agent_schema": agent_name,
|
|
1373
1502
|
"input_text": input_text,
|
|
1374
1503
|
}
|
|
1375
1504
|
|
|
1505
|
+
# Only include text_response if content was NOT streamed
|
|
1506
|
+
# When streaming, child_content events already delivered the content
|
|
1507
|
+
if not use_streaming or not streamed_content:
|
|
1508
|
+
response["text_response"] = str(result.output)
|
|
1509
|
+
|
|
1510
|
+
return response
|
|
1511
|
+
|
|
1376
1512
|
|
|
1377
1513
|
# =============================================================================
|
|
1378
1514
|
# Test/Debug Tools (for development only)
|