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.

Files changed (51) hide show
  1. loom/__init__.py +1 -0
  2. loom/adapters/converters.py +77 -0
  3. loom/adapters/registry.py +43 -0
  4. loom/api/factory.py +77 -0
  5. loom/api/main.py +201 -0
  6. loom/builtin/__init__.py +3 -0
  7. loom/builtin/memory/__init__.py +3 -0
  8. loom/builtin/memory/metabolic.py +96 -0
  9. loom/builtin/memory/pso.py +41 -0
  10. loom/builtin/memory/sanitizers.py +39 -0
  11. loom/builtin/memory/validators.py +55 -0
  12. loom/config/tool.py +63 -0
  13. loom/infra/__init__.py +0 -0
  14. loom/infra/llm.py +43 -0
  15. loom/infra/logging.py +42 -0
  16. loom/infra/store.py +39 -0
  17. loom/infra/transport/memory.py +85 -0
  18. loom/infra/transport/nats.py +141 -0
  19. loom/infra/transport/redis.py +140 -0
  20. loom/interfaces/llm.py +44 -0
  21. loom/interfaces/memory.py +50 -0
  22. loom/interfaces/store.py +29 -0
  23. loom/interfaces/transport.py +35 -0
  24. loom/kernel/__init__.py +0 -0
  25. loom/kernel/base_interceptor.py +97 -0
  26. loom/kernel/bus.py +76 -0
  27. loom/kernel/dispatcher.py +58 -0
  28. loom/kernel/interceptors/__init__.py +14 -0
  29. loom/kernel/interceptors/budget.py +60 -0
  30. loom/kernel/interceptors/depth.py +45 -0
  31. loom/kernel/interceptors/hitl.py +51 -0
  32. loom/kernel/interceptors/studio.py +137 -0
  33. loom/kernel/interceptors/timeout.py +27 -0
  34. loom/kernel/state.py +71 -0
  35. loom/memory/hierarchical.py +94 -0
  36. loom/node/__init__.py +0 -0
  37. loom/node/agent.py +133 -0
  38. loom/node/base.py +121 -0
  39. loom/node/crew.py +103 -0
  40. loom/node/router.py +68 -0
  41. loom/node/tool.py +50 -0
  42. loom/protocol/__init__.py +0 -0
  43. loom/protocol/cloudevents.py +73 -0
  44. loom/protocol/interfaces.py +110 -0
  45. loom/protocol/mcp.py +97 -0
  46. loom/protocol/memory_operations.py +51 -0
  47. loom/protocol/patch.py +93 -0
  48. loom_agent-0.3.2.dist-info/LICENSE +204 -0
  49. loom_agent-0.3.2.dist-info/METADATA +139 -0
  50. loom_agent-0.3.2.dist-info/RECORD +51 -0
  51. 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
+ )