loom-agent 0.3.2__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 loom-agent might be problematic. Click here for more details.
- loom/__init__.py +1 -0
- loom/adapters/converters.py +77 -0
- loom/adapters/registry.py +43 -0
- loom/api/factory.py +77 -0
- loom/api/main.py +201 -0
- loom/builtin/__init__.py +3 -0
- loom/builtin/memory/__init__.py +3 -0
- loom/builtin/memory/metabolic.py +96 -0
- loom/builtin/memory/pso.py +41 -0
- loom/builtin/memory/sanitizers.py +39 -0
- loom/builtin/memory/validators.py +55 -0
- loom/config/tool.py +63 -0
- loom/infra/__init__.py +0 -0
- loom/infra/llm.py +43 -0
- loom/infra/logging.py +42 -0
- loom/infra/store.py +39 -0
- loom/infra/transport/memory.py +85 -0
- loom/infra/transport/nats.py +141 -0
- loom/infra/transport/redis.py +140 -0
- loom/interfaces/llm.py +44 -0
- loom/interfaces/memory.py +50 -0
- loom/interfaces/store.py +29 -0
- loom/interfaces/transport.py +35 -0
- loom/kernel/__init__.py +0 -0
- loom/kernel/base_interceptor.py +97 -0
- loom/kernel/bus.py +76 -0
- loom/kernel/dispatcher.py +58 -0
- loom/kernel/interceptors/__init__.py +14 -0
- loom/kernel/interceptors/budget.py +60 -0
- loom/kernel/interceptors/depth.py +45 -0
- loom/kernel/interceptors/hitl.py +51 -0
- loom/kernel/interceptors/studio.py +137 -0
- loom/kernel/interceptors/timeout.py +27 -0
- loom/kernel/state.py +71 -0
- loom/memory/hierarchical.py +94 -0
- loom/node/__init__.py +0 -0
- loom/node/agent.py +133 -0
- loom/node/base.py +121 -0
- loom/node/crew.py +103 -0
- loom/node/router.py +68 -0
- loom/node/tool.py +50 -0
- loom/protocol/__init__.py +0 -0
- loom/protocol/cloudevents.py +73 -0
- loom/protocol/interfaces.py +110 -0
- loom/protocol/mcp.py +97 -0
- loom/protocol/memory_operations.py +51 -0
- loom/protocol/patch.py +93 -0
- loom_agent-0.3.2.dist-info/LICENSE +204 -0
- loom_agent-0.3.2.dist-info/METADATA +139 -0
- loom_agent-0.3.2.dist-info/RECORD +51 -0
- loom_agent-0.3.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hierarchical Memory Implementation
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import List, Dict, Any, Optional
|
|
7
|
+
|
|
8
|
+
from loom.interfaces.memory import MemoryInterface, MemoryEntry
|
|
9
|
+
|
|
10
|
+
class HierarchicalMemory(MemoryInterface):
|
|
11
|
+
"""
|
|
12
|
+
A simplified 4-tier memory system.
|
|
13
|
+
|
|
14
|
+
Tiers:
|
|
15
|
+
1. Ephemeral: Tool outputs (not implemented separate storage in this MVP, just tagged)
|
|
16
|
+
2. Working: Recent N items.
|
|
17
|
+
3. Session: Full conversation history.
|
|
18
|
+
4. Long-term: (Stub) Vector interactions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, session_limit: int = 100, working_limit: int = 5):
|
|
22
|
+
self.session_limit = session_limit
|
|
23
|
+
self.working_limit = working_limit
|
|
24
|
+
|
|
25
|
+
self._session: List[MemoryEntry] = []
|
|
26
|
+
# working memory is a dynamic view or separate buffer?
|
|
27
|
+
# In legacy design, it was promoted. Here, let's treat it as "Recent Window" logic for start.
|
|
28
|
+
|
|
29
|
+
async def add(self, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
30
|
+
"""Add to session memory."""
|
|
31
|
+
# print(f"[DEBUG Memory] Adding {role}: {content[:20]}...")
|
|
32
|
+
metadata = metadata or {}
|
|
33
|
+
tier = metadata.get("tier", "session")
|
|
34
|
+
|
|
35
|
+
entry = MemoryEntry(
|
|
36
|
+
role=role,
|
|
37
|
+
content=content,
|
|
38
|
+
timestamp=time.time(),
|
|
39
|
+
metadata=metadata,
|
|
40
|
+
tier=tier
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self._session.append(entry)
|
|
44
|
+
|
|
45
|
+
# Enforce limits
|
|
46
|
+
if len(self._session) > self.session_limit:
|
|
47
|
+
self._session.pop(0) # Simple FIFO
|
|
48
|
+
|
|
49
|
+
async def get_context(self, task: str = "") -> str:
|
|
50
|
+
"""
|
|
51
|
+
Construct a context string for the Agent.
|
|
52
|
+
|
|
53
|
+
Format:
|
|
54
|
+
--- Long Term Memory ---
|
|
55
|
+
(Stub)
|
|
56
|
+
|
|
57
|
+
--- Session History ---
|
|
58
|
+
User: ...
|
|
59
|
+
Assistant: ...
|
|
60
|
+
"""
|
|
61
|
+
# Long term stub
|
|
62
|
+
long_term = ""
|
|
63
|
+
|
|
64
|
+
# Working/Session view
|
|
65
|
+
# We perform a simple sliding window for now suitable for LLM Context Window
|
|
66
|
+
# or return full session if it fits.
|
|
67
|
+
|
|
68
|
+
history_str = []
|
|
69
|
+
for entry in self._session:
|
|
70
|
+
history_str.append(f"{entry.role.capitalize()}: {entry.content}")
|
|
71
|
+
|
|
72
|
+
return "\n".join(history_str)
|
|
73
|
+
|
|
74
|
+
async def get_recent(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
75
|
+
"""Get recent raw messages for LLM API."""
|
|
76
|
+
messages = []
|
|
77
|
+
for entry in self._session[-limit:]:
|
|
78
|
+
msg = {"role": entry.role, "content": entry.content}
|
|
79
|
+
|
|
80
|
+
# Include tool_calls for assistant messages
|
|
81
|
+
if entry.role == "assistant" and "tool_calls" in entry.metadata:
|
|
82
|
+
msg["tool_calls"] = entry.metadata["tool_calls"]
|
|
83
|
+
|
|
84
|
+
# Include tool_call_id for tool messages
|
|
85
|
+
if entry.role == "tool" and "tool_call_id" in entry.metadata:
|
|
86
|
+
msg["tool_call_id"] = entry.metadata["tool_call_id"]
|
|
87
|
+
if "tool_name" in entry.metadata:
|
|
88
|
+
msg["name"] = entry.metadata["tool_name"]
|
|
89
|
+
|
|
90
|
+
messages.append(msg)
|
|
91
|
+
return messages
|
|
92
|
+
|
|
93
|
+
async def clear(self) -> None:
|
|
94
|
+
self._session.clear()
|
loom/node/__init__.py
ADDED
|
File without changes
|
loom/node/agent.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Node (Fractal System)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from loom.protocol.cloudevents import CloudEvent
|
|
9
|
+
from loom.node.base import Node
|
|
10
|
+
from loom.node.tool import ToolNode
|
|
11
|
+
from loom.kernel.dispatcher import Dispatcher
|
|
12
|
+
|
|
13
|
+
from loom.interfaces.llm import LLMProvider
|
|
14
|
+
from loom.infra.llm import MockLLMProvider
|
|
15
|
+
from loom.interfaces.memory import MemoryInterface
|
|
16
|
+
from loom.memory.hierarchical import HierarchicalMemory
|
|
17
|
+
|
|
18
|
+
class AgentNode(Node):
|
|
19
|
+
"""
|
|
20
|
+
A Node that acts as an Intelligent Agent (MCP Client).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
node_id: str,
|
|
26
|
+
dispatcher: Dispatcher,
|
|
27
|
+
role: str = "Assistant",
|
|
28
|
+
system_prompt: str = "You are a helpful assistant.",
|
|
29
|
+
tools: Optional[List[ToolNode]] = None,
|
|
30
|
+
provider: Optional[LLMProvider] = None,
|
|
31
|
+
memory: Optional[MemoryInterface] = None
|
|
32
|
+
):
|
|
33
|
+
super().__init__(node_id, dispatcher)
|
|
34
|
+
self.role = role
|
|
35
|
+
self.system_prompt = system_prompt
|
|
36
|
+
self.known_tools = {t.tool_def.name: t for t in tools} if tools else {}
|
|
37
|
+
# Replaced internal list list with Memory Interface
|
|
38
|
+
self.memory = memory or HierarchicalMemory()
|
|
39
|
+
self.provider = provider or MockLLMProvider()
|
|
40
|
+
|
|
41
|
+
async def process(self, event: CloudEvent) -> Any:
|
|
42
|
+
"""
|
|
43
|
+
Agent Loop with Memory:
|
|
44
|
+
1. Receive Task -> Add to Memory
|
|
45
|
+
2. Get Context from Memory
|
|
46
|
+
3. Think (LLM)
|
|
47
|
+
4. Tool Call -> Add Result to Memory
|
|
48
|
+
5. Final Response
|
|
49
|
+
"""
|
|
50
|
+
return await self._execute_loop(event)
|
|
51
|
+
|
|
52
|
+
async def _execute_loop(self, event: CloudEvent) -> Any:
|
|
53
|
+
"""
|
|
54
|
+
Execute the ReAct Loop.
|
|
55
|
+
"""
|
|
56
|
+
task = event.data.get("task", "") or event.data.get("content", "")
|
|
57
|
+
max_iterations = event.data.get("max_iterations", 5)
|
|
58
|
+
|
|
59
|
+
# 1. Perceive (Add to Memory)
|
|
60
|
+
await self.memory.add("user", task)
|
|
61
|
+
|
|
62
|
+
iterations = 0
|
|
63
|
+
final_response = ""
|
|
64
|
+
|
|
65
|
+
while iterations < max_iterations:
|
|
66
|
+
iterations += 1
|
|
67
|
+
|
|
68
|
+
# 2. Recall (Get Context)
|
|
69
|
+
history = await self.memory.get_recent(limit=20)
|
|
70
|
+
messages = [{"role": "system", "content": self.system_prompt}] + history
|
|
71
|
+
|
|
72
|
+
# 3. Think
|
|
73
|
+
mcp_tools = [t.tool_def.model_dump() for t in self.known_tools.values()]
|
|
74
|
+
response = await self.provider.chat(messages, tools=mcp_tools)
|
|
75
|
+
final_text = response.content
|
|
76
|
+
|
|
77
|
+
# 4. Act (Tool Usage or Final Answer)
|
|
78
|
+
if response.tool_calls:
|
|
79
|
+
# Record the "thought" / call intent
|
|
80
|
+
# ALWAYS store assistant message with tool_calls (even if content is empty)
|
|
81
|
+
await self.memory.add("assistant", final_text or "", metadata={
|
|
82
|
+
"tool_calls": response.tool_calls
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# Execute tools (Parallel support possible, here sequential)
|
|
86
|
+
for tc in response.tool_calls:
|
|
87
|
+
tc_name = tc.get("name")
|
|
88
|
+
tc_args = tc.get("arguments")
|
|
89
|
+
|
|
90
|
+
# Emit thought event
|
|
91
|
+
await self.dispatcher.dispatch(CloudEvent.create(
|
|
92
|
+
source=self.source_uri,
|
|
93
|
+
type="agent.thought",
|
|
94
|
+
data={"thought": f"Calling {tc_name}", "tool_call": tc},
|
|
95
|
+
traceparent=event.traceparent
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
target_tool = self.known_tools.get(tc_name)
|
|
99
|
+
|
|
100
|
+
if target_tool:
|
|
101
|
+
tool_event = CloudEvent.create(
|
|
102
|
+
source=self.source_uri,
|
|
103
|
+
type="node.request",
|
|
104
|
+
data={"arguments": tc_args},
|
|
105
|
+
subject=target_tool.source_uri,
|
|
106
|
+
traceparent=event.traceparent
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
tool_result_evt = await target_tool.process(tool_event)
|
|
110
|
+
result_content = tool_result_evt.get("result")
|
|
111
|
+
|
|
112
|
+
# Add Result to Memory (Observation)
|
|
113
|
+
# We use 'tool' role.
|
|
114
|
+
await self.memory.add("tool", str(result_content), metadata={"tool_name": tc_name, "tool_call_id": tc.get("id")})
|
|
115
|
+
else:
|
|
116
|
+
err_msg = f"Tool {tc_name} not found."
|
|
117
|
+
await self.memory.add("system", err_msg)
|
|
118
|
+
|
|
119
|
+
# Loop continues to reflect on tool results
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
# Final Answer
|
|
124
|
+
await self.memory.add("assistant", final_text)
|
|
125
|
+
final_response = final_text
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
if not final_response and iterations >= max_iterations:
|
|
129
|
+
final_response = "Error: Maximum iterations reached without final answer."
|
|
130
|
+
await self.memory.add("system", final_response)
|
|
131
|
+
|
|
132
|
+
return {"response": final_response, "iterations": iterations}
|
|
133
|
+
|
loom/node/base.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Node Abstraction (Fractal System)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from loom.protocol.cloudevents import CloudEvent
|
|
11
|
+
from loom.kernel.dispatcher import Dispatcher
|
|
12
|
+
|
|
13
|
+
class Node(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Abstract Base Class for all Fractal Nodes (Agent, Tool, Crew).
|
|
16
|
+
Implements standard event subscription and request handling.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, node_id: str, dispatcher: Dispatcher):
|
|
20
|
+
self.node_id = node_id
|
|
21
|
+
self.dispatcher = dispatcher
|
|
22
|
+
self.source_uri = f"/node/{node_id}" # Standard URI
|
|
23
|
+
|
|
24
|
+
# Auto-subscribe to my requests
|
|
25
|
+
asyncio.create_task(self._subscribe_to_events())
|
|
26
|
+
|
|
27
|
+
async def _subscribe_to_events(self):
|
|
28
|
+
"""Subscribe to 'node.request' targeting this node."""
|
|
29
|
+
topic = f"node.request/{self.source_uri.strip('/')}"
|
|
30
|
+
await self.dispatcher.bus.subscribe(topic, self._handle_request)
|
|
31
|
+
|
|
32
|
+
async def _handle_request(self, event: CloudEvent):
|
|
33
|
+
"""
|
|
34
|
+
Standard request handler.
|
|
35
|
+
1. Calls node-specific process()
|
|
36
|
+
2. Dispatches response/result/error
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# 1. Process
|
|
40
|
+
result = await self.process(event)
|
|
41
|
+
|
|
42
|
+
# 2. Respond
|
|
43
|
+
response_event = CloudEvent.create(
|
|
44
|
+
source=self.source_uri,
|
|
45
|
+
type="node.response",
|
|
46
|
+
data={
|
|
47
|
+
"request_id": event.id,
|
|
48
|
+
"result": result
|
|
49
|
+
},
|
|
50
|
+
traceparent=event.traceparent
|
|
51
|
+
)
|
|
52
|
+
# Response topic usually goes to whoever asked, or open bus
|
|
53
|
+
# In request-reply pattern, typically we might just publish it
|
|
54
|
+
# and the caller subscribes to node.response/originator
|
|
55
|
+
|
|
56
|
+
# For now, just generic publish
|
|
57
|
+
await self.dispatcher.dispatch(response_event)
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
error_event = CloudEvent.create(
|
|
61
|
+
source=self.source_uri,
|
|
62
|
+
type="node.error",
|
|
63
|
+
data={
|
|
64
|
+
"request_id": event.id,
|
|
65
|
+
"error": str(e)
|
|
66
|
+
},
|
|
67
|
+
traceparent=event.traceparent
|
|
68
|
+
)
|
|
69
|
+
await self.dispatcher.dispatch(error_event)
|
|
70
|
+
|
|
71
|
+
async def call(self, target_node: str, data: Dict[str, Any]) -> Any:
|
|
72
|
+
"""
|
|
73
|
+
Call another node and wait for response.
|
|
74
|
+
"""
|
|
75
|
+
request_id = str(uuid4())
|
|
76
|
+
request_event = CloudEvent.create(
|
|
77
|
+
source=self.source_uri,
|
|
78
|
+
type="node.request",
|
|
79
|
+
data=data,
|
|
80
|
+
subject=target_node,
|
|
81
|
+
)
|
|
82
|
+
request_event.id = request_id
|
|
83
|
+
|
|
84
|
+
# Subscribe to response
|
|
85
|
+
# Using Broadcast Reply pattern: listen to target's responses
|
|
86
|
+
response_future = asyncio.Future()
|
|
87
|
+
|
|
88
|
+
async def handle_response(event: CloudEvent):
|
|
89
|
+
if event.data and event.data.get("request_id") == request_id:
|
|
90
|
+
if not response_future.done():
|
|
91
|
+
if event.type == "node.error":
|
|
92
|
+
response_future.set_exception(Exception(event.data.get("error", "Unknown Error")))
|
|
93
|
+
else:
|
|
94
|
+
response_future.set_result(event.data.get("result"))
|
|
95
|
+
|
|
96
|
+
# Topic: node.response/{target_node}
|
|
97
|
+
# Note: clean URI
|
|
98
|
+
target_topic = f"node.response/{target_node.strip('/')}"
|
|
99
|
+
|
|
100
|
+
# We need access to bus directly or via dispatcher
|
|
101
|
+
# Dispatcher has .bus
|
|
102
|
+
await self.dispatcher.bus.subscribe(target_topic, handle_response)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Dispatch request
|
|
106
|
+
await self.dispatcher.dispatch(request_event)
|
|
107
|
+
|
|
108
|
+
# Wait for response
|
|
109
|
+
return await asyncio.wait_for(response_future, timeout=30.0)
|
|
110
|
+
finally:
|
|
111
|
+
# Cleanup subscription?
|
|
112
|
+
# Current bus implementation doesn't support unsubscribe easily,
|
|
113
|
+
# but in-memory transport might accumulate handlers.
|
|
114
|
+
# TODO: Add unsubscribe to Transport Protocol to prevent leaks.
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
async def process(self, event: CloudEvent) -> Any:
|
|
120
|
+
"""Core logic."""
|
|
121
|
+
pass
|
loom/node/crew.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crew Node (Orchestrator)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, List, Literal
|
|
6
|
+
|
|
7
|
+
from loom.protocol.cloudevents import CloudEvent
|
|
8
|
+
from loom.node.agent import AgentNode
|
|
9
|
+
from loom.node.base import Node
|
|
10
|
+
from loom.kernel.dispatcher import Dispatcher
|
|
11
|
+
from loom.protocol.memory_operations import ContextSanitizer
|
|
12
|
+
from loom.builtin.memory.sanitizers import BubbleUpSanitizer
|
|
13
|
+
|
|
14
|
+
class CrewNode(Node):
|
|
15
|
+
"""
|
|
16
|
+
A Node that orchestrates other Nodes (recursive composition).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
node_id: str,
|
|
22
|
+
dispatcher: Dispatcher,
|
|
23
|
+
agents: List[AgentNode],
|
|
24
|
+
pattern: Literal["sequential", "parallel"] = "sequential",
|
|
25
|
+
sanitizer: ContextSanitizer = None
|
|
26
|
+
):
|
|
27
|
+
super().__init__(node_id, dispatcher)
|
|
28
|
+
self.agents = agents
|
|
29
|
+
self.pattern = pattern
|
|
30
|
+
self.sanitizer = sanitizer or BubbleUpSanitizer()
|
|
31
|
+
|
|
32
|
+
async def process(self, event: CloudEvent) -> Any:
|
|
33
|
+
"""
|
|
34
|
+
Execute the crew pattern.
|
|
35
|
+
"""
|
|
36
|
+
task = event.data.get("task", "")
|
|
37
|
+
|
|
38
|
+
if self.pattern == "sequential":
|
|
39
|
+
return await self._execute_sequential(task, event.traceparent)
|
|
40
|
+
|
|
41
|
+
return {"error": "Unsupported pattern"}
|
|
42
|
+
|
|
43
|
+
async def _execute_sequential(self, task: str, traceparent: str = None) -> Any:
|
|
44
|
+
"""
|
|
45
|
+
Chain agents sequentially. A -> B -> C
|
|
46
|
+
"""
|
|
47
|
+
current_input = task
|
|
48
|
+
chain_results = []
|
|
49
|
+
|
|
50
|
+
for agent in self.agents:
|
|
51
|
+
# 1. Create Event for Agent
|
|
52
|
+
event = CloudEvent.create(
|
|
53
|
+
source=self.source_uri,
|
|
54
|
+
type="node.request",
|
|
55
|
+
data={"task": current_input},
|
|
56
|
+
subject=agent.source_uri,
|
|
57
|
+
traceparent=traceparent
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 2. Invoke (Directly for MVP sync flow, see AgentNode discussion)
|
|
61
|
+
try:
|
|
62
|
+
result = await agent.process(event)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
# Robustness: Capture error but don't crash chain immediately?
|
|
65
|
+
# Or abort?
|
|
66
|
+
# For now, we abort but return error struct.
|
|
67
|
+
return {
|
|
68
|
+
"error": f"Agent {agent.node_id} failed: {str(e)}",
|
|
69
|
+
"trace": chain_results
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# 3. Process output
|
|
73
|
+
response = result.get("response", "")
|
|
74
|
+
|
|
75
|
+
# Sanitization (Fractal Metabolism)
|
|
76
|
+
# Limit the bubble-up context to ~200 chars or reasonable token limit per agent to prevent pollution
|
|
77
|
+
sanitized_response = await self.sanitizer.sanitize(str(response), target_token_limit=100)
|
|
78
|
+
|
|
79
|
+
chain_results.append({
|
|
80
|
+
"agent": agent.node_id,
|
|
81
|
+
"output": response, # Full output in trace
|
|
82
|
+
"sanitized": sanitized_response
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# 4. Pass to next
|
|
86
|
+
# We pass the full output to the immediate next strictly?
|
|
87
|
+
# Or do we pass the accumulated context?
|
|
88
|
+
# In "Sequential", A's output is B's input.
|
|
89
|
+
# If A outputs 5000 tokens, B is overwhelmed.
|
|
90
|
+
# So passing sanitized response is better for long chains unless full data needed.
|
|
91
|
+
# But "task" usually implies specific instruction.
|
|
92
|
+
# If sequential is "Refine this artifact", we need full artifact.
|
|
93
|
+
# If sequential is "Research -> Planner", we need summary.
|
|
94
|
+
|
|
95
|
+
# Design choice: For generic Crew, we pass full output (trusting agent to handle it or memory to metabolize it).
|
|
96
|
+
# BUT the return value of THIS CrewNode to its parent should be sanitized or structured.
|
|
97
|
+
|
|
98
|
+
current_input = response
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"final_output": current_input,
|
|
102
|
+
"trace": chain_results
|
|
103
|
+
}
|
loom/node/router.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Router Node (Attention Mechanism)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, List, Dict
|
|
6
|
+
from loom.protocol.cloudevents import CloudEvent
|
|
7
|
+
from loom.node.base import Node
|
|
8
|
+
from loom.node.agent import AgentNode
|
|
9
|
+
from loom.kernel.dispatcher import Dispatcher
|
|
10
|
+
from loom.interfaces.llm import LLMProvider
|
|
11
|
+
|
|
12
|
+
class AttentionRouter(Node):
|
|
13
|
+
"""
|
|
14
|
+
Intelligent Router that routes tasks to the best suited Agent based on description.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
node_id: str,
|
|
20
|
+
dispatcher: Dispatcher,
|
|
21
|
+
agents: List[AgentNode],
|
|
22
|
+
provider: LLMProvider
|
|
23
|
+
):
|
|
24
|
+
super().__init__(node_id, dispatcher)
|
|
25
|
+
self.agents = {agent.node_id: agent for agent in agents}
|
|
26
|
+
self.provider = provider
|
|
27
|
+
# Agent descriptions map
|
|
28
|
+
self.registry = {agent.node_id: agent.role for agent in agents}
|
|
29
|
+
|
|
30
|
+
async def process(self, event: CloudEvent) -> Any:
|
|
31
|
+
task = event.data.get("task", "")
|
|
32
|
+
if not task:
|
|
33
|
+
return {"error": "No task provided"}
|
|
34
|
+
|
|
35
|
+
# 1. Construct Prompt
|
|
36
|
+
options = "\n".join([f"- {aid}: {role}" for aid, role in self.registry.items()])
|
|
37
|
+
prompt = f"""
|
|
38
|
+
You are a routing system. Given the task, select the best agent ID to handle it.
|
|
39
|
+
Return ONLY the agent ID.
|
|
40
|
+
|
|
41
|
+
Agents:
|
|
42
|
+
{options}
|
|
43
|
+
|
|
44
|
+
Task: {task}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# 2. LLM Select
|
|
48
|
+
# Simple chat call
|
|
49
|
+
response = await self.provider.chat([{"role": "user", "content": prompt}])
|
|
50
|
+
selected_id = response.content.strip()
|
|
51
|
+
|
|
52
|
+
# Clean up potential extra chars/whitespace
|
|
53
|
+
# Iterate keys to find match if fuzzy
|
|
54
|
+
target_agent = None
|
|
55
|
+
for aid in self.agents:
|
|
56
|
+
if aid in selected_id:
|
|
57
|
+
target_agent = self.agents[aid]
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
if not target_agent:
|
|
61
|
+
return {"error": f"Could not route task. Selected: {selected_id}"}
|
|
62
|
+
|
|
63
|
+
# 3. Dispatch to Target
|
|
64
|
+
# Request-Reply: We wait for the agent and return its result.
|
|
65
|
+
# Use our Node.call mechanism!
|
|
66
|
+
|
|
67
|
+
result = await self.call(target_agent.source_uri, {"task": task})
|
|
68
|
+
return {"result": result, "routed_to": target_agent.node_id}
|
loom/node/tool.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Node (Fractal System)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict
|
|
6
|
+
|
|
7
|
+
from loom.protocol.cloudevents import CloudEvent
|
|
8
|
+
from loom.protocol.mcp import MCPToolDefinition
|
|
9
|
+
from loom.node.base import Node
|
|
10
|
+
from loom.kernel.dispatcher import Dispatcher
|
|
11
|
+
|
|
12
|
+
class ToolNode(Node):
|
|
13
|
+
"""
|
|
14
|
+
A Node that acts as an MCP Server for a single tool.
|
|
15
|
+
Reference Implementation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
node_id: str,
|
|
21
|
+
dispatcher: Dispatcher,
|
|
22
|
+
tool_def: MCPToolDefinition,
|
|
23
|
+
func: Callable[[Dict[str, Any]], Any]
|
|
24
|
+
):
|
|
25
|
+
super().__init__(node_id, dispatcher)
|
|
26
|
+
self.tool_def = tool_def
|
|
27
|
+
self.func = func
|
|
28
|
+
|
|
29
|
+
async def process(self, event: CloudEvent) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Execute the tool.
|
|
32
|
+
Expects event.data to contain 'arguments'.
|
|
33
|
+
"""
|
|
34
|
+
args = event.data.get("arguments", {})
|
|
35
|
+
|
|
36
|
+
# In a real system, validate against self.tool_def.input_schema
|
|
37
|
+
|
|
38
|
+
# Execute
|
|
39
|
+
try:
|
|
40
|
+
# Check if func is async
|
|
41
|
+
import inspect
|
|
42
|
+
if inspect.iscoroutinefunction(self.func):
|
|
43
|
+
result = await self.func(args)
|
|
44
|
+
else:
|
|
45
|
+
result = self.func(args)
|
|
46
|
+
|
|
47
|
+
return {"result": result}
|
|
48
|
+
except Exception as e:
|
|
49
|
+
# Re-raise to trigger node.error in Base Node
|
|
50
|
+
raise RuntimeError(f"Tool execution failed: {e}")
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CloudEvents v1.0 Implementation for Loom
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
12
|
+
|
|
13
|
+
class CloudEvent(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
CloudEvents 1.0 Specification Implementation.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
specversion: The version of the CloudEvents specification which the event uses.
|
|
19
|
+
id: Identifies the event.
|
|
20
|
+
source: Identifies the context in which an event happened.
|
|
21
|
+
type: Describes the type of event related to the originating occurrence.
|
|
22
|
+
datacontenttype: Content type of data value.
|
|
23
|
+
dataschema: Identifies the schema that data adheres to.
|
|
24
|
+
subject: Describes the subject of the event in the context of the event producer (identified by source).
|
|
25
|
+
time: Timestamp of when the occurrence happened.
|
|
26
|
+
data: The event payload.
|
|
27
|
+
traceparent: W3C Trace Context (Extension)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Required Attributes
|
|
31
|
+
specversion: str = "1.0"
|
|
32
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
33
|
+
source: str
|
|
34
|
+
type: str # e.g., "node.call", "agent.thought"
|
|
35
|
+
|
|
36
|
+
# Optional Attributes
|
|
37
|
+
datacontenttype: Optional[str] = "application/json"
|
|
38
|
+
dataschema: Optional[str] = None
|
|
39
|
+
subject: Optional[str] = None
|
|
40
|
+
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
41
|
+
data: Optional[Any] = None
|
|
42
|
+
|
|
43
|
+
# Extensions
|
|
44
|
+
traceparent: Optional[str] = None
|
|
45
|
+
extensions: Dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(
|
|
48
|
+
populate_by_name=True,
|
|
49
|
+
json_encoders={datetime: lambda v: v.isoformat()},
|
|
50
|
+
extra='allow'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
54
|
+
"""Convert to standard CloudEvents dictionary structure."""
|
|
55
|
+
return self.model_dump(exclude_none=True, by_alias=True)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create(
|
|
59
|
+
cls,
|
|
60
|
+
source: str,
|
|
61
|
+
type: str,
|
|
62
|
+
data: Optional[Any] = None,
|
|
63
|
+
subject: Optional[str] = None,
|
|
64
|
+
traceparent: Optional[str] = None
|
|
65
|
+
) -> "CloudEvent":
|
|
66
|
+
"""Factory method to create a CloudEvent."""
|
|
67
|
+
return cls(
|
|
68
|
+
source=source,
|
|
69
|
+
type=type,
|
|
70
|
+
data=data,
|
|
71
|
+
subject=subject,
|
|
72
|
+
traceparent=traceparent
|
|
73
|
+
)
|