Repository: griptape-ai/griptape

Issue:
Support Adding Multiple Children With Bitshift Operators
When using the [bitshift operator](https://docs.griptape.ai/stable/griptape-framework/structures/workflows/#bitshift-composition), users should be able to specify multiple tasks. I.e.:

```python
parent >> [child_1, child_2, child_3]
```

Hints from issue comments:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32970814..b25a058c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - `Agent.output_schema` for setting an output schema to be used on the Agent's Prompt Task.
 - `BasePromptDriver.structured_output_strategy` for changing the Structured Output strategy between `native`, `tool`, and `rule`.
 
+### Changed
+- Task bitshift operators can now take a list of Tasks.
+
 ### Fixed
 
 - Occasional crash during `FuturesExecutorMixin` cleanup.
diff --git a/docs/examples/src/load_and_query_pinecone_1.py b/docs/examples/src/load_and_query_pinecone_1.py
index 6aad6655..bc3cdf5e 100644
--- a/docs/examples/src/load_and_query_pinecone_1.py
+++ b/docs/examples/src/load_and_query_pinecone_1.py
@@ -8,7 +8,7 @@ from griptape.drivers import OpenAiEmbeddingDriver, PineconeVectorStoreDriver
 
 def load_data(driver: PineconeVectorStoreDriver) -> None:
     response = urlopen(
-        "https://raw.githubusercontent.com/wedeploy-examples/" "supermarket-web-example/master/products.json"
+        "https://raw.githubusercontent.com/wedeploy-examples/supermarket-web-example/master/products.json"
     )
 
     for product in json.loads(response.read()):
diff --git a/docs/examples/src/query_webpage_astra_db_1.py b/docs/examples/src/query_webpage_astra_db_1.py
index 8c71fbe5..2e337b52 100644
--- a/docs/examples/src/query_webpage_astra_db_1.py
+++ b/docs/examples/src/query_webpage_astra_db_1.py
@@ -53,4 +53,4 @@ rag_tool = RagTool(
     rag_engine=engine,
 )
 agent = Agent(tools=[rag_tool])
-agent.run("What engine made possible to index such an amount of data, " "and what kind of tuning was required?")
+agent.run("What engine made possible to index such an amount of data, and what kind of tuning was required?")
diff --git a/docs/examples/src/talk_to_a_webpage_1.py b/docs/examples/src/talk_to_a_webpage_1.py
index 5414c876..23ec21ba 100644
--- a/docs/examples/src/talk_to_a_webpage_1.py
+++ b/docs/examples/src/talk_to_a_webpage_1.py
@@ -32,7 +32,7 @@ chunks = TextChunker().chunk(artifacts)
 vector_store_driver.upsert_text_artifacts({namespace: chunks})
 
 rag_tool = RagTool(
-    description="Contains information about physics. " "Use it to answer any physics-related questions.",
+    description="Contains information about physics. Use it to answer any physics-related questions.",
     rag_engine=engine,
 )
 
diff --git a/docs/griptape-framework/drivers/src/web_scraper_drivers_1.py b/docs/griptape-framework/drivers/src/web_scraper_drivers_1.py
index 8b9e9dbd..8bdcc495 100644
--- a/docs/griptape-framework/drivers/src/web_scraper_drivers_1.py
+++ b/docs/griptape-framework/drivers/src/web_scraper_drivers_1.py
@@ -7,7 +7,7 @@ query_params = [
     "js_render=false",
     "premium_proxy=false",
 ]
-proxy_url = f'http://{os.environ["ZENROWS_API_KEY"]}:{"&".join(query_params)}@proxy.zenrows.com:8001'
+proxy_url = f"http://{os.environ['ZENROWS_API_KEY']}:{'&'.join(query_params)}@proxy.zenrows.com:8001"
 
 driver = ProxyWebScraperDriver(
     proxies={
diff --git a/docs/griptape-framework/structures/rulesets.md b/docs/griptape-framework/structures/rulesets.md
index 0104a94d..06d24c74 100644
--- a/docs/griptape-framework/structures/rulesets.md
+++ b/docs/griptape-framework/structures/rulesets.md
@@ -13,6 +13,11 @@ A [Ruleset](../../reference/griptape/rules/ruleset.md) can be used to define [Ru
 
 [Rule](../../reference/griptape/rules/base_rule.md)s shape the LLM's behavior by defining specific guidelines or instructions for how it should interpret and respond to inputs. Rules can be used to modify language style, tone, or even behavior based on what you define.
 
+!!! tip
+    Avoid writing large amounts of text in a single Rule.
+    Breaking down your Rules generally helps the LLM follow them more effectively. Additionally, it makes it easier to evaluate the Rule's effectiveness using tools like the [Eval Engine](../engines/eval-engines.md).
+    If you have an existing system prompt, consider [overriding the default system prompt](#overriding-system-prompts) instead.
+
 ```python
 --8<-- "docs/griptape-framework/structures/src/basic_rule.py"
 ```
@@ -152,3 +157,18 @@ You can pass [rules](../../reference/griptape/mixins/rule_mixin.md#griptape.mixi
                                "emoji_response": "😊👍"
                              }
 ```
+
+## Overriding System Prompts
+
+While Rulesets are the preferred way to steer LLM output, sometimes you may want to fully override the system prompt.
+
+```python
+--8<-- "docs/griptape-framework/structures/src/generate_system_prompt.py"
+```
+
+Note that when overriding the system prompt, it is your responsibility to integrate anything that goes in [by default](https://github.com/griptape-ai/griptape/blob/6b31c129fc19a9ba4bdb205ad9e2a40aef9b121f/griptape/tasks/prompt_task.py?plain=1#L216-L221).
+You can achieve this by appending the default system prompt to your custom prompt like so:
+
+```python
+--8<-- "docs/griptape-framework/structures/src/generate_system_prompt_with_rules.py"
+```
diff --git a/docs/griptape-framework/structures/src/generate_system_prompt.py b/docs/griptape-framework/structures/src/generate_system_prompt.py
new file mode 100644
index 00000000..7d150665
--- /dev/null
+++ b/docs/griptape-framework/structures/src/generate_system_prompt.py
@@ -0,0 +1,25 @@
+from textwrap import dedent
+
+from griptape.rules import Rule
+from griptape.structures import Pipeline
+from griptape.tasks.prompt_task import PromptTask
+
+pipeline = Pipeline(
+    tasks=[
+        PromptTask(
+            rules=[Rule("Your favorite color is blue")],  # Will be ignored!
+            generate_system_template=lambda _: dedent("""
+                Employment:
+                    - Behave like a polite customer support agent
+                    - Act like you work for company SkaterWorld, Inc.
+                    - Discuss only topics related to skateboarding
+                    - Limit your response to fewer than 5 sentences.
+
+                Background:
+                    - Your name is Todd
+                """),
+        )
+    ]
+)
+
+pipeline.run("What is your name and favorite color?")
diff --git a/docs/griptape-framework/structures/src/generate_system_prompt_with_rules.py b/docs/griptape-framework/structures/src/generate_system_prompt_with_rules.py
new file mode 100644
index 00000000..1ec97aca
--- /dev/null
+++ b/docs/griptape-framework/structures/src/generate_system_prompt_with_rules.py
@@ -0,0 +1,28 @@
+from textwrap import dedent
+
+from griptape.rules import Rule
+from griptape.structures import Pipeline
+from griptape.tasks.prompt_task import PromptTask
+
+pipeline = Pipeline(
+    tasks=[
+        PromptTask(
+            rules=[Rule("Your favorite color is blue")],
+            generate_system_template=lambda task: dedent(
+                """
+                Employment:
+                    - Behave like a polite customer support agent
+                    - Act like you work for company SkaterWorld, Inc.
+                    - Discuss only topics related to skateboarding
+                    - Limit your response to fewer than 5 sentences.
+
+                Background:
+                    - Your name is Todd
+                """
+                + task.default_generate_system_template(task)
+            ),
+        )
+    ]
+)
+
+pipeline.run("What is your name and favorite color?")
diff --git a/docs/griptape-framework/structures/workflows.md b/docs/griptape-framework/structures/workflows.md
index f1eff199..59b2711e 100644
--- a/docs/griptape-framework/structures/workflows.md
+++ b/docs/griptape-framework/structures/workflows.md
@@ -208,7 +208,7 @@ output:
 
 ### Bitshift Composition
 
-Task relationships can also be set up with the Python bitshift operators `>>` and `<<`. The following four statements are all functionally equivalent:
+Task relationships can also be set up with the Python bitshift operators `>>` and `<<`. The following statements are all functionally equivalent:
 
 ```python
 task1 >> task2
@@ -216,6 +216,9 @@ task1.add_child(task2)
 
 task2 << task1
 task2.add_parent(task1)
+
+task3 >> [task4, task5]
+task3.add_children([task4, task5])
 ```
 
 When using the bitshift to compose operators, the relationship is set in the direction that the bitshift operator points.
diff --git a/docs/griptape-tools/official-tools/src/variation_image_generation_tool_1.py b/docs/griptape-tools/official-tools/src/variation_image_generation_tool_1.py
index bce8b84f..9f6e4e39 100644
--- a/docs/griptape-tools/official-tools/src/variation_image_generation_tool_1.py
+++ b/docs/griptape-tools/official-tools/src/variation_image_generation_tool_1.py
@@ -17,5 +17,5 @@ tool = VariationImageGenerationTool(
 
 # Create an agent and provide the tool to it.
 Agent(tools=[tool]).run(
-    "Generate a variation of the image located at tests/resources/mountain.png " "depicting a mountain on a winter day"
+    "Generate a variation of the image located at tests/resources/mountain.png depicting a mountain on a winter day"
 )
diff --git a/griptape/configs/logging/truncate_logging_filter.py b/griptape/configs/logging/truncate_logging_filter.py
index 9888fc16..ff6562a1 100644
--- a/griptape/configs/logging/truncate_logging_filter.py
+++ b/griptape/configs/logging/truncate_logging_filter.py
@@ -12,6 +12,6 @@ class TruncateLoggingFilter(logging.Filter):
         message = record.getMessage()
 
         if len(message) > self.max_log_length:
-            record.msg = f"{message[:self.max_log_length]}... [{len(message) - self.max_log_length} more characters]"
+            record.msg = f"{message[: self.max_log_length]}... [{len(message) - self.max_log_length} more characters]"
             record.args = ()
         return True
diff --git a/griptape/engines/rag/stages/retrieval_rag_stage.py b/griptape/engines/rag/stages/retrieval_rag_stage.py
index c6ff1e0e..de4af094 100644
--- a/griptape/engines/rag/stages/retrieval_rag_stage.py
+++ b/griptape/engines/rag/stages/retrieval_rag_stage.py
@@ -50,7 +50,7 @@ class RetrievalRagStage(BaseRagStage):
         chunks_after_dedup = len(results)
 
         logging.info(
-            "RetrievalRagStage: deduplicated %s " "chunks (%s - %s)",
+            "RetrievalRagStage: deduplicated %s chunks (%s - %s)",
             chunks_before_dedup - chunks_after_dedup,
             chunks_before_dedup,
             chunks_after_dedup,
diff --git a/griptape/tasks/base_task.py b/griptape/tasks/base_task.py
index 54b839ab..e0036b1a 100644
--- a/griptape/tasks/base_task.py
+++ b/griptape/tasks/base_task.py
@@ -41,13 +41,19 @@ class BaseTask(FuturesExecutorMixin, SerializableMixin, RunnableMixin["BaseTask"
     output: Optional[BaseArtifact] = field(default=None, init=False)
     context: dict[str, Any] = field(factory=dict, kw_only=True, metadata={"serializable": True})
 
-    def __rshift__(self, other: BaseTask) -> BaseTask:
-        self.add_child(other)
+    def __rshift__(self, other: BaseTask | list[BaseTask]) -> BaseTask | list[BaseTask]:
+        if isinstance(other, list):
+            self.add_children(other)
+        else:
+            self.add_child(other)
 
         return other
 
-    def __lshift__(self, other: BaseTask) -> BaseTask:
-        self.add_parent(other)
+    def __lshift__(self, other: BaseTask | list[BaseTask]) -> BaseTask | list[BaseTask]:
+        if isinstance(other, list):
+            self.add_parents(other)
+        else:
+            self.add_parent(other)
 
         return other
 
diff --git a/griptape/tools/rest_api/tool.py b/griptape/tools/rest_api/tool.py
index f5e233e5..497a1234 100644
--- a/griptape/tools/rest_api/tool.py
+++ b/griptape/tools/rest_api/tool.py
@@ -218,6 +218,6 @@ class RestApiTool(BaseTool):
             url += path.strip("/")
 
         if path_params:
-            url += f'/{str.join("/", map(str, path_params))}'
+            url += f"/{str.join('/', map(str, path_params))}"
 
         return urljoin(base_url.strip("/"), url)
diff --git a/pyproject.toml b/pyproject.toml
index fe771260..752890c7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -219,7 +219,7 @@ torch = "^2.4.1"
 optional = true
 
 [tool.poetry.group.dev.dependencies]
-ruff = "^0.8.0"
+ruff = "^0.9.1"
 pyright = "^1.1.376"
 pre-commit = "^4.0.0"
 boto3-stubs = {extras = ["bedrock", "iam", "opensearch", "s3", "sagemaker", "sqs", "iot-data", "dynamodb", "redshift-data"], version = "^1.34.105"}


Test patch:
diff --git a/tests/unit/chunkers/test_markdown_chunker.py b/tests/unit/chunkers/test_markdown_chunker.py
index 436e09b1..4518f3c3 100644
--- a/tests/unit/chunkers/test_markdown_chunker.py
+++ b/tests/unit/chunkers/test_markdown_chunker.py
@@ -15,11 +15,11 @@ class TestTextChunker:
         text = [
             "## Header 1\n",
             gen_paragraph(MAX_TOKENS // 2, chunker.tokenizer, ". "),
-            "\n" "## Header 2\n",
+            "\n## Header 2\n",
             gen_paragraph(MAX_TOKENS // 2, chunker.tokenizer, ". "),
             "\n\n",
             gen_paragraph(MAX_TOKENS // 2, chunker.tokenizer, ". "),
-            "\n" "## Header 3\n",
+            "\n## Header 3\n",
             gen_paragraph(MAX_TOKENS * 2, chunker.tokenizer, ". "),
         ]
         chunks = chunker.chunk("".join(text))
diff --git a/tests/unit/tasks/test_actions_subtask.py b/tests/unit/tasks/test_actions_subtask.py
index 764c3440..30dd2c80 100644
--- a/tests/unit/tasks/test_actions_subtask.py
+++ b/tests/unit/tasks/test_actions_subtask.py
@@ -164,7 +164,7 @@ class TestActionsSubtask:
         assert json_dict[0].get("input") is None
 
     def test_no_actions(self):
-        valid_input = "Thought: need to test\n" "
: test observation\n" "Answer: test output"
+        valid_input = "Thought: need to test\n
: test observation\nAnswer: test output"
 
         task = PromptTask(tools=[MockTool()])
         Agent().add_task(task)
@@ -174,7 +174,7 @@ class TestActionsSubtask:
         assert len(json_dict) == 0
 
     def test_empty_actions(self):
-        valid_input = "Thought: need to test\n" "Actions: []\n" "
: test observation\n" "Answer: test output"
+        valid_input = "Thought: need to test\nActions: []\n
: test observation\nAnswer: test output"
 
         task = PromptTask(tools=[MockTool()])
         Agent().add_task(task)
@@ -184,9 +184,7 @@ class TestActionsSubtask:
         assert len(json_dict) == 0
 
     def test_invalid_actions(self):
-        invalid_input = (
-            "Thought: need to test\n" "Actions: [{,{]\n" "
: test observation\n" "Answer: test output"
-        )
+        invalid_input = "Thought: need to test\nActions: [{,{]\n
: test observation\nAnswer: test output"
 
         task = PromptTask(tools=[MockTool()])
         Agent().add_task(task)
diff --git a/tests/unit/tasks/test_base_task.py b/tests/unit/tasks/test_base_task.py
index 0f6787ed..8ceea4c7 100644
--- a/tests/unit/tasks/test_base_task.py
+++ b/tests/unit/tasks/test_base_task.py
@@ -151,6 +151,18 @@ class TestBaseTask:
         assert task.id in parent.child_ids
         assert added_task == parent
 
+    def test_add_multiple_parent_bitshift(self, task):
+        parent_1 = MockTask("parent foobar", id="parent_1_foobar")
+        parent_2 = MockTask("parent foobar", id="parent_2_foobar")
+
+        added_tasks = task << [parent_1, parent_2]
+
+        assert parent_1.id in task.parent_ids
+        assert parent_2.id in task.parent_ids
+        assert task.id in parent_1.child_ids
+        assert task.id in parent_2.child_ids
+        assert added_tasks == [parent_1, parent_2]
+
     def test_add_child_bitshift(self, task):
         child = MockTask("child foobar", id="child_foobar")
 
@@ -160,6 +172,18 @@ class TestBaseTask:
         assert task.id in child.parent_ids
         assert added_task == child
 
+    def test_add_multiple_child_bitshift(self, task):
+        child_1 = MockTask("child foobar", id="child_1_foobar")
+        child_2 = MockTask("child foobar", id="child_2_foobar")
+
+        added_tasks = task >> [child_1, child_2]
+
+        assert child_1.id in task.child_ids
+        assert child_2.id in task.child_ids
+        assert task.id in child_1.parent_ids
+        assert task.id in child_2.parent_ids
+        assert added_tasks == [child_1, child_2]
+
     def test_to_dict(self, task):
         expected_task_dict = {
             "type": task.type,