dissyslab 1.0.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.
Files changed (89) hide show
  1. dissyslab/__init__.py +25 -0
  2. dissyslab/blocks/__init__.py +32 -0
  3. dissyslab/blocks/fanin.py +90 -0
  4. dissyslab/blocks/fanout.py +69 -0
  5. dissyslab/blocks/merge_synch.py +73 -0
  6. dissyslab/blocks/role.py +113 -0
  7. dissyslab/blocks/sink.py +77 -0
  8. dissyslab/blocks/source.py +119 -0
  9. dissyslab/blocks/split.py +110 -0
  10. dissyslab/blocks/transform.py +82 -0
  11. dissyslab/builder.py +222 -0
  12. dissyslab/cli.py +239 -0
  13. dissyslab/components/__init__.py +24 -0
  14. dissyslab/components/sinks/__init__.py +13 -0
  15. dissyslab/components/sinks/console_display.py +66 -0
  16. dissyslab/components/sinks/demo_email_alerter.py +98 -0
  17. dissyslab/components/sinks/discard.py +28 -0
  18. dissyslab/components/sinks/file_system.py +70 -0
  19. dissyslab/components/sinks/gmail_sink.py +135 -0
  20. dissyslab/components/sinks/intelligence_display.py +123 -0
  21. dissyslab/components/sinks/llm_builders.py +36 -0
  22. dissyslab/components/sinks/mcp_sink.py +204 -0
  23. dissyslab/components/sinks/photo_dashboard.py +222 -0
  24. dissyslab/components/sinks/replay_csv_in.py +72 -0
  25. dissyslab/components/sinks/rl_dashboard.py +165 -0
  26. dissyslab/components/sinks/sine_mixture_source.py +160 -0
  27. dissyslab/components/sinks/sink_jsonl_recorder.py +58 -0
  28. dissyslab/components/sinks/sink_list_collector.py +33 -0
  29. dissyslab/components/sinks/sink_simple_file.py +38 -0
  30. dissyslab/components/sinks/test_llm_enricher.py +129 -0
  31. dissyslab/components/sinks/test_webhook.py +14 -0
  32. dissyslab/components/sinks/webhook_sink.py +186 -0
  33. dissyslab/components/sources/__init__.py +16 -0
  34. dissyslab/components/sources/bluesky_jetstream_source.py +235 -0
  35. dissyslab/components/sources/calendar_source.py +290 -0
  36. dissyslab/components/sources/cartpole_source.py +238 -0
  37. dissyslab/components/sources/clock_source.py +96 -0
  38. dissyslab/components/sources/demo_bluesky_jetstream.py +401 -0
  39. dissyslab/components/sources/demo_job_source.py +42 -0
  40. dissyslab/components/sources/demo_rss_source.py +292 -0
  41. dissyslab/components/sources/file_source.py +273 -0
  42. dissyslab/components/sources/generated/__init__.py +0 -0
  43. dissyslab/components/sources/generated/stocks_source.py +45 -0
  44. dissyslab/components/sources/generated/weather_source.py +111 -0
  45. dissyslab/components/sources/gmail_source.py +249 -0
  46. dissyslab/components/sources/image_folder_source.py +147 -0
  47. dissyslab/components/sources/list_source.py +30 -0
  48. dissyslab/components/sources/mcp_source.py +272 -0
  49. dissyslab/components/sources/natural_numbers_source.py +40 -0
  50. dissyslab/components/sources/rss_normalizer.py +328 -0
  51. dissyslab/components/sources/rss_source.py +196 -0
  52. dissyslab/components/sources/test_bluesky.py +16 -0
  53. dissyslab/components/sources/web_scraper.py +463 -0
  54. dissyslab/components/transformers/__init__.py +17 -0
  55. dissyslab/components/transformers/ai_agent.py +154 -0
  56. dissyslab/components/transformers/composition_analyzer.py +199 -0
  57. dissyslab/components/transformers/demo_ai_agent.py +230 -0
  58. dissyslab/components/transformers/demo_jobs.py +141 -0
  59. dissyslab/components/transformers/demo_salary.py +122 -0
  60. dissyslab/components/transformers/demo_sentiment.py +102 -0
  61. dissyslab/components/transformers/demo_spam.py +140 -0
  62. dissyslab/components/transformers/demo_topic.py +160 -0
  63. dissyslab/components/transformers/demo_urgency.py +149 -0
  64. dissyslab/components/transformers/exposure_analyzer.py +177 -0
  65. dissyslab/components/transformers/learning_curve_analyzer.py +187 -0
  66. dissyslab/components/transformers/policy_analyzer.py +168 -0
  67. dissyslab/components/transformers/prompts.py +563 -0
  68. dissyslab/components/transformers/reward_analyzer.py +141 -0
  69. dissyslab/components/transformers/sharpness_analyzer.py +149 -0
  70. dissyslab/components/transformers/stateful_agent.py +170 -0
  71. dissyslab/composed_agent.py +212 -0
  72. dissyslab/core.py +320 -0
  73. dissyslab/network.py +759 -0
  74. dissyslab/office/__init__.py +17 -0
  75. dissyslab/office/make_network.py +368 -0
  76. dissyslab/office/make_office.py +296 -0
  77. dissyslab/office/office_compiler.py +230 -0
  78. dissyslab/office/utils.py +916 -0
  79. dissyslab/os_agent.py +186 -0
  80. dissyslab/py.typed +0 -0
  81. dissyslab/utils/__init__.py +31 -0
  82. dissyslab/utils/get_credentials.py +172 -0
  83. dissyslab/utils/visualize.py +491 -0
  84. dissyslab-1.0.2.dist-info/METADATA +188 -0
  85. dissyslab-1.0.2.dist-info/RECORD +89 -0
  86. dissyslab-1.0.2.dist-info/WHEEL +5 -0
  87. dissyslab-1.0.2.dist-info/entry_points.txt +2 -0
  88. dissyslab-1.0.2.dist-info/licenses/LICENSE +15 -0
  89. dissyslab-1.0.2.dist-info/top_level.txt +1 -0
dissyslab/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # dissyslab/__init__.py
2
+ """
3
+ DisSysLab - Distributed Systems Teaching Framework
4
+
5
+ Public API exports.
6
+ """
7
+
8
+ from dissyslab.core import Agent, ExceptionThread
9
+ from dissyslab.network import Network
10
+ from dissyslab.builder import network, PortReference
11
+
12
+ __all__ = [
13
+ # Core
14
+ 'Agent',
15
+ 'ExceptionThread',
16
+
17
+ # Network
18
+ 'Network',
19
+ 'network',
20
+
21
+ # Builder
22
+ 'PortReference',
23
+ ]
24
+
25
+ __version__ = '1.0.2'
@@ -0,0 +1,32 @@
1
+ # dissyslab/blocks/__init__.py
2
+ """
3
+ Standard agent blocks for building distributed systems.
4
+
5
+ Available blocks:
6
+ - Source: Generate messages (no inputs)
7
+ - Transform: Process messages (one input, one output)
8
+ - Sink: Consume messages (one input, no outputs)
9
+ - Broadcast: Fanout - copy message to multiple outputs
10
+ - MergeAsynch: Fanin - merge multiple inputs into one stream
11
+ - Split: Content-based routing to multiple outputs
12
+ """
13
+
14
+ from dissyslab.blocks.source import Source
15
+ from dissyslab.blocks.transform import Transform
16
+ from dissyslab.blocks.sink import Sink
17
+ from dissyslab.blocks.fanout import Broadcast
18
+ from dissyslab.blocks.fanin import MergeAsynch
19
+ from dissyslab.blocks.merge_synch import MergeSynch
20
+ from dissyslab.blocks.split import Split
21
+ from dissyslab.blocks.role import Role
22
+
23
+ __all__ = [
24
+ "Source",
25
+ "Transform",
26
+ "Sink",
27
+ "Broadcast",
28
+ "MergeAsynch",
29
+ "MergeSynch",
30
+ "Split",
31
+ "Role",
32
+ ]
@@ -0,0 +1,90 @@
1
+ # dissyslab/blocks/fanin.py
2
+ """
3
+ Merge Agents: Combine multiple inputs into single output (fanin).
4
+
5
+ MergeAsynch is the recommended merge for most use cases. Termination is
6
+ signaled by os_agent via _Shutdown, handled transparently by recv().
7
+ No STOP coordination needed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import Optional
12
+ import threading
13
+
14
+ from dissyslab.core import Agent, _ShutdownSignal
15
+
16
+
17
+ class MergeAsynch(Agent):
18
+ """
19
+ MergeAsynch agent: combines multiple inputs (fanin, non-deterministic).
20
+
21
+ Multiple inputs, single output. Receives from whichever input has
22
+ a message available first. Fast but non-deterministic order.
23
+
24
+ **Ports:**
25
+ - Inports: ["in_0", "in_1", ..., "in_{n-1}"]
26
+ - Outports: ["out_"]
27
+
28
+ **Termination:**
29
+ Termination is detected by os_agent and signaled via _Shutdown on
30
+ each inport, handled transparently by recv(). No STOP coordination
31
+ needed — worker threads exit cleanly via _ShutdownSignal.
32
+ """
33
+
34
+ def __init__(self, *, num_inputs: int, name: Optional[str] = None):
35
+ if num_inputs < 1:
36
+ raise ValueError(
37
+ f"MergeAsynch requires at least 1 input, got {num_inputs}"
38
+ )
39
+
40
+ inports = [f"in_{i}" for i in range(num_inputs)]
41
+ super().__init__(name=name, inports=inports, outports=["out_"])
42
+ self.num_inputs = num_inputs
43
+
44
+ @property
45
+ def default_inport(self) -> Optional[str]:
46
+ """No default input (multiple inputs - ambiguous)."""
47
+ return None
48
+
49
+ @property
50
+ def default_outport(self) -> str:
51
+ """Default output port for edge syntax."""
52
+ return "out_"
53
+
54
+ def _worker(self, port: str) -> None:
55
+ """
56
+ Worker thread for one input port.
57
+ Forwards messages to out_ until _ShutdownSignal is raised by recv().
58
+ """
59
+ try:
60
+ while True:
61
+ msg = self.recv(port)
62
+ self.send(msg, "out_")
63
+ except _ShutdownSignal:
64
+ pass # clean exit — os_agent declared termination
65
+
66
+ def run(self) -> None:
67
+ """
68
+ Spawn one worker thread per input port and wait for all to finish.
69
+
70
+ Workers exit when recv() raises _ShutdownSignal on _Shutdown receipt.
71
+ """
72
+ threads = []
73
+ for p in self.inports:
74
+ t = threading.Thread(
75
+ target=self._worker,
76
+ args=(p,),
77
+ name=f"merge_worker_{p}",
78
+ daemon=False
79
+ )
80
+ t.start()
81
+ threads.append(t)
82
+
83
+ for t in threads:
84
+ t.join()
85
+
86
+ def __repr__(self) -> str:
87
+ return f"<MergeAsynch name={self.name} inputs={self.num_inputs}>"
88
+
89
+ def __str__(self) -> str:
90
+ return f"MergeAsynch({self.num_inputs} inputs)"
@@ -0,0 +1,69 @@
1
+ # dissyslab/blocks/fanout.py
2
+ """
3
+ Broadcast Agent: Copies messages to multiple outputs (fanout).
4
+
5
+ Broadcast agents are automatically inserted by the framework when one
6
+ agent connects to multiple receivers. Termination is signaled by os_agent
7
+ via _Shutdown, handled transparently by recv().
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import Optional
12
+ import copy
13
+
14
+ from dissyslab.core import Agent
15
+
16
+
17
+ class Broadcast(Agent):
18
+ """
19
+ Broadcast agent: copies messages to multiple outputs (fanout).
20
+
21
+ Single input, multiple outputs. Receives message and sends a deep
22
+ copy to each output port.
23
+
24
+ **Ports:**
25
+ - Inports: ["in_"]
26
+ - Outports: ["out_0", "out_1", ..., "out_{n-1}"]
27
+
28
+ **Termination:**
29
+ Termination is detected by os_agent and signaled via _Shutdown,
30
+ which recv() handles transparently by raising _ShutdownSignal.
31
+ """
32
+
33
+ def __init__(self, *, num_outputs: int, name: Optional[str] = None):
34
+ if num_outputs < 1:
35
+ raise ValueError(
36
+ f"Broadcast requires at least 1 output, got {num_outputs}"
37
+ )
38
+
39
+ outports = [f"out_{i}" for i in range(num_outputs)]
40
+ super().__init__(name=name, inports=["in_"], outports=outports)
41
+ self.num_outputs = num_outputs
42
+
43
+ @property
44
+ def default_inport(self) -> str:
45
+ """Default input port for edge syntax."""
46
+ return "in_"
47
+
48
+ @property
49
+ def default_outport(self) -> Optional[str]:
50
+ """No default output (multiple outputs - ambiguous)."""
51
+ return None
52
+
53
+ def run(self) -> None:
54
+ """
55
+ Broadcast messages to all outputs.
56
+
57
+ recv() intercepts _Shutdown and raises _ShutdownSignal,
58
+ which unwinds this loop cleanly.
59
+ """
60
+ while True:
61
+ msg = self.recv("in_")
62
+ for i in range(self.num_outputs):
63
+ self.send(copy.deepcopy(msg), f"out_{i}")
64
+
65
+ def __repr__(self) -> str:
66
+ return f"<Broadcast name={self.name} outputs={self.num_outputs}>"
67
+
68
+ def __str__(self) -> str:
69
+ return f"Broadcast({self.num_outputs} outputs)"
@@ -0,0 +1,73 @@
1
+ # dissyslab/blocks/merge_synch.py
2
+ """
3
+ Merge Agents: Combine multiple inputs into single output.
4
+
5
+ MergeSynch: Synchronous merge in round-robin order (use sparingly).
6
+ For most use cases, prefer MergeAsynch in fanin.py.
7
+
8
+ Termination is signaled by os_agent via _Shutdown, handled transparently
9
+ by recv(). No explicit STOP handling needed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ from typing import Optional
14
+
15
+ from dissyslab.core import Agent
16
+
17
+
18
+ class MergeSynch(Agent):
19
+ """
20
+ MergeSynch agent: synchronously combines multiple inputs in round-robin order.
21
+
22
+ **WARNING: Use only when deterministic ordering is required AND all inputs
23
+ produce messages at similar rates. For most use cases, prefer MergeAsynch.**
24
+
25
+ **Ports:**
26
+ - Inports: ["in_0", "in_1", ..., "in_{n-1}"]
27
+ - Outports: ["out_"]
28
+
29
+ **Termination:**
30
+ Termination is detected by os_agent and signaled via _Shutdown,
31
+ which recv() handles transparently by raising _ShutdownSignal.
32
+ """
33
+
34
+ def __init__(self, *, num_inputs: int, name: Optional[str] = None):
35
+ if num_inputs < 1:
36
+ raise ValueError(
37
+ f"MergeSynch requires at least 1 input, got {num_inputs}"
38
+ )
39
+
40
+ inports = [f"in_{i}" for i in range(num_inputs)]
41
+ super().__init__(name=name, inports=inports, outports=["out_"])
42
+ self.num_inputs = num_inputs
43
+
44
+ @property
45
+ def default_inport(self) -> Optional[str]:
46
+ """No default input (multiple inputs - ambiguous)."""
47
+ return None
48
+
49
+ @property
50
+ def default_outport(self) -> str:
51
+ """Default output port for edge syntax."""
52
+ return "out_"
53
+
54
+ def run(self) -> None:
55
+ """
56
+ Synchronous merge loop: collect batches in round-robin order.
57
+
58
+ Collects one message from each input in order, sends as a list.
59
+ recv() intercepts _Shutdown and raises _ShutdownSignal,
60
+ which unwinds this loop cleanly.
61
+ """
62
+ while True:
63
+ batch = []
64
+ for inport in self.inports:
65
+ msg = self.recv(inport)
66
+ batch.append(msg)
67
+ self.send(batch, "out_")
68
+
69
+ def __repr__(self) -> str:
70
+ return f"<MergeSynch name={self.name} inputs={self.num_inputs}>"
71
+
72
+ def __str__(self) -> str:
73
+ return f"MergeSynch({self.num_inputs} inputs)"
@@ -0,0 +1,113 @@
1
+ # dissyslab/blocks/role.py
2
+ """
3
+ Role Agent: Routes messages based on status strings.
4
+
5
+ A Role agent is a generalization of Split where:
6
+ - The function returns an arbitrary list of (message, status) pairs
7
+ - Status strings determine which outport each message goes to
8
+ - The number of output messages is independent of the number of outports
9
+
10
+ Termination is signaled by os_agent via _Shutdown, handled transparently
11
+ by recv(). No explicit STOP handling needed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+ from typing import Callable, Any, Optional, List, Tuple
16
+ import traceback
17
+
18
+ from dissyslab.core import Agent
19
+
20
+
21
+ class Role(Agent):
22
+ """
23
+ Role agent: routes messages based on status strings.
24
+
25
+ Single input, multiple outputs. The function returns either:
26
+ (1) an arbitrary list of (message, status) pairs, or
27
+ (2) a list of messages without explicit status values — coerced to "all", or
28
+ (3) a single message (not a list) — treated as [(message, "all")], or
29
+ (4) None — message is dropped.
30
+
31
+ **Ports:**
32
+ - Inports: ["in_"]
33
+ - Outports: ["out_0", "out_1", ..., "out_{n-1}"]
34
+ where n = len(statuses)
35
+
36
+ **Termination:**
37
+ Termination is detected by os_agent and signaled via _Shutdown,
38
+ which recv() handles transparently by raising _ShutdownSignal.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ fn: Callable[[Any], List[Tuple[Any, str]]],
45
+ statuses: List[str],
46
+ name: Optional[str] = None
47
+ ):
48
+ if not callable(fn):
49
+ raise TypeError(
50
+ f"Role fn must be callable, got {type(fn).__name__}"
51
+ )
52
+
53
+ if not statuses:
54
+ statuses = ["all"]
55
+
56
+ if len(set(statuses)) != len(statuses):
57
+ raise ValueError(
58
+ f"Role statuses must be unique, got duplicates: {statuses}"
59
+ )
60
+
61
+ self._status_to_port: dict = {
62
+ status: f"out_{i}" for i, status in enumerate(statuses)
63
+ }
64
+
65
+ outports = [f"out_{i}" for i in range(len(statuses))]
66
+
67
+ super().__init__(name=name, inports=["in_"], outports=outports)
68
+ self._fn = fn
69
+ self.statuses = list(statuses)
70
+
71
+ def run(self) -> None:
72
+ """
73
+ Process messages and route by status.
74
+
75
+ recv() intercepts _Shutdown and raises _ShutdownSignal,
76
+ which unwinds this loop cleanly.
77
+ """
78
+ while True:
79
+ msg = self.recv("in_")
80
+
81
+ try:
82
+ results = self._fn(msg)
83
+
84
+ if results is None:
85
+ continue
86
+
87
+ if not isinstance(results, (list, tuple)):
88
+ results = [(results, "all")]
89
+ elif results and not isinstance(results[0], (list, tuple)):
90
+ results = [(item, "all") for item in results]
91
+
92
+ for out_msg, status in results:
93
+ if status not in self._status_to_port:
94
+ raise ValueError(
95
+ f"Role '{self.name}' returned undeclared status "
96
+ f"'{status}'. Declared statuses: {self.statuses}"
97
+ )
98
+ self.send(out_msg, self._status_to_port[status])
99
+
100
+ except Exception as e:
101
+ print(f"[Role '{self.name}'] Error in fn: {e}")
102
+ print(traceback.format_exc())
103
+ return
104
+
105
+ def __repr__(self) -> str:
106
+ fn_name = getattr(self._fn, "__name__", repr(self._fn))
107
+ return (
108
+ f"<Role name={self.name} fn={fn_name} "
109
+ f"statuses={self.statuses}>"
110
+ )
111
+
112
+ def __str__(self) -> str:
113
+ return f"Role({self.statuses})"
@@ -0,0 +1,77 @@
1
+ # dissyslab/blocks/sink.py
2
+ """
3
+ Sink Agent: Consumes messages for side effects.
4
+
5
+ Sinks have one input and no outputs. They are terminal nodes that call
6
+ fn(msg, **params) for each message to perform side effects like printing,
7
+ saving, or collecting. Termination is signaled by os_agent via _Shutdown,
8
+ which is handled transparently by recv().
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from typing import Any, Callable, Optional, Dict
13
+ import traceback
14
+
15
+ from dissyslab.core import Agent
16
+
17
+
18
+ class Sink(Agent):
19
+ """
20
+ Sink agent: consumes messages for side effects.
21
+
22
+ Single input, no outputs. Terminal node that calls fn(msg, **params)
23
+ for each message. Used for actions like printing, saving, or sending.
24
+
25
+ **Ports:**
26
+ - Inports: ["in_"]
27
+ - Outports: [] (no outputs)
28
+
29
+ **Termination:**
30
+ Termination is detected by os_agent and signaled via _Shutdown,
31
+ which recv() handles transparently by raising _ShutdownSignal.
32
+ No explicit STOP handling needed.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ fn: Callable[..., None],
39
+ name: Optional[str] = None,
40
+ params: Optional[Dict[str, Any]] = None
41
+ ):
42
+ if not callable(fn):
43
+ raise TypeError(
44
+ f"Sink fn must be callable, got {type(fn).__name__}"
45
+ )
46
+
47
+ super().__init__(name=name, inports=["in_"], outports=[])
48
+ self._fn = fn
49
+ self._params = params or {}
50
+
51
+ @property
52
+ def default_inport(self) -> str:
53
+ """Default input port for edge syntax."""
54
+ return "in_"
55
+
56
+ def run(self) -> None:
57
+ """
58
+ Process messages until _Shutdown is received.
59
+
60
+ recv() intercepts _Shutdown and raises _ShutdownSignal,
61
+ which unwinds this loop cleanly.
62
+ """
63
+ while True:
64
+ msg = self.recv("in_")
65
+ try:
66
+ self._fn(msg, **self._params)
67
+ except Exception as e:
68
+ print(f"[Sink '{self.name}'] Error in fn: {e}")
69
+ print(traceback.format_exc())
70
+ return
71
+
72
+ def __repr__(self) -> str:
73
+ fn_name = getattr(self._fn, "__name__", repr(self._fn))
74
+ return f"<Sink name={self.name} fn={fn_name}>"
75
+
76
+ def __str__(self) -> str:
77
+ return "Sink"
@@ -0,0 +1,119 @@
1
+ # dissyslab/blocks/source.py
2
+ """
3
+ Source Agent: Repeatedly calls a function to generate messages.
4
+
5
+ Sources have no inputs and generate data by calling fn() repeatedly until
6
+ it returns None. When exhausted, the source sends a termination message to
7
+ os_agent with its final sent counts. Termination is detected by os_agent —
8
+ sources do not send STOP signals.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import inspect
13
+ import traceback
14
+ import time
15
+ from typing import Any, Callable, Optional
16
+
17
+ from dissyslab.core import Agent
18
+
19
+
20
+ class Source(Agent):
21
+ """
22
+ Source Agent: Repeatedly calls a function to generate messages.
23
+
24
+ **Ports:**
25
+ - Inports: [] (no inputs - sources generate data)
26
+ - Outports: ["out_"] (emits generated messages)
27
+
28
+ **Function Requirements:**
29
+ The fn callable must:
30
+ - Return a message (any type) on each call
31
+ - Return None when exhausted (no more messages)
32
+ - Maintain its own state between calls (if needed)
33
+
34
+ Generator functions are also accepted — Source wraps them automatically
35
+ so that each call to fn() advances the generator by one step.
36
+
37
+ **Termination:**
38
+ When fn() returns None, the source sends a termination message to
39
+ os_agent containing its final sent counts, then returns from run().
40
+ os_agent uses this message to detect when all sources are done.
41
+
42
+ **Optional Rate Limiting:**
43
+ The interval parameter adds a delay between messages:
44
+ - interval=0 (default): emit as fast as possible
45
+ - interval=1.0: emit one message per second
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ fn: Callable[[], Optional[Any]],
52
+ name: Optional[str] = None,
53
+ interval: float = 0
54
+ ):
55
+ if not callable(fn):
56
+ raise TypeError(
57
+ "Source fn must be callable with signature: fn() -> Optional[message]"
58
+ )
59
+
60
+ super().__init__(name=name, inports=[], outports=["out_"])
61
+
62
+ # If fn is a generator function, wrap it so each call returns one item.
63
+ if inspect.isgeneratorfunction(fn):
64
+ _gen = fn()
65
+ def fn(): return next(_gen, None)
66
+
67
+ self._fn = fn
68
+ self._interval = interval
69
+
70
+ @property
71
+ def default_outport(self) -> str:
72
+ """Default output port for edge syntax."""
73
+ return "out_"
74
+
75
+ def _send_termination(self) -> None:
76
+ """
77
+ Send final sent counts to os_agent.
78
+ Called when source is exhausted or encounters an error.
79
+ """
80
+ self.send_os({
81
+ "agent": self.name,
82
+ "sent": dict(self.sent),
83
+ "received": {},
84
+ })
85
+
86
+ def run(self) -> None:
87
+ """
88
+ Main processing loop for the Source agent.
89
+
90
+ Repeatedly calls self._fn() to get messages and emits them.
91
+ When fn() returns None or an exception occurs, sends termination
92
+ message to os_agent and returns.
93
+ """
94
+ try:
95
+ while True:
96
+ msg = self._fn()
97
+
98
+ # None means the source is exhausted
99
+ if msg is None:
100
+ self._send_termination()
101
+ return
102
+
103
+ self.send(msg, "out_")
104
+
105
+ if self._interval > 0:
106
+ time.sleep(self._interval)
107
+
108
+ except Exception as e:
109
+ print(f"[Source '{self.name}'] Error in fn: {e}")
110
+ print(traceback.format_exc())
111
+ self._send_termination()
112
+
113
+ def __repr__(self) -> str:
114
+ fn_name = getattr(self._fn, "__name__", repr(self._fn))
115
+ interval_str = f", interval={self._interval}" if self._interval > 0 else ""
116
+ return f"<Source name={self.name} fn={fn_name}{interval_str}>"
117
+
118
+ def __str__(self) -> str:
119
+ return "Source"