Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add task relationship APIs #822

Merged
merged 3 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added
- `BaseTask.add_child()` to add a child task to a parent task.
- `BaseTask.add_children()` to add multiple child tasks to a parent task.
- `BaseTask.add_parent()` to add a parent task to a child task.
- `BaseTask.add_parents()` to add multiple parent tasks to a child task.
- `Structure.resolve_relationships()` to resolve asymmetrically defined parent/child relationships. In other words, if a parent declares a child, but the child does not declare the parent, the parent will automatically be added as a parent of the child when running this method. The method is invoked automatically by `Structure.before_run()`.

### Changed
- **BREAKING**: `Workflow` no longer modifies task relationships when adding tasks via `tasks` init param, `add_tasks()` or `add_task()`. Previously, adding a task would automatically add the previously added task as its parent. Existing code that relies on this behavior will need to be updated to explicitly add parent/child relationships using the API offered by `BaseTask`.
- `Structure.before_run()` now automatically resolves asymmetrically defined parent/child relationships using the new `Structure.resolve_relationships()`.
- Updated `HuggingFaceHubPromptDriver` to use `transformers`'s `apply_chat_template`.
- Updated `HuggingFacePipelinePromptDriver` to use chat features of `transformers.TextGenerationPipeline`.

### Fixed
- `Workflow.insert_task()` no longer inserts duplicate tasks when given multiple parent tasks.

## [0.26.0] - 2024-06-04

### Added
Expand Down
44 changes: 21 additions & 23 deletions docs/examples/multi-agent-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,35 +155,33 @@ if __name__ == "__main__":
),
),
)
end_task = team.add_task(
PromptTask(
'State "All Done!"',
)
)
team.insert_tasks(
research_task,
[
StructureRunTask(
(
"""Using insights provided, develop an engaging blog
writer_tasks = team.add_tasks(*[
StructureRunTask(
(
"""Using insights provided, develop an engaging blog
post that highlights the most significant AI advancements.
Your post should be informative yet accessible, catering to a tech-savvy audience.
Make it sound cool, avoid complex words so it doesn't sound like AI.

Insights:
{{ parent_outputs["research"] }}""",
),
driver=LocalStructureRunDriver(
structure_factory_fn=lambda: build_writer(
role=writer["role"],
goal=writer["goal"],
backstory=writer["backstory"],
)
),
)
for writer in WRITERS
],
end_task,
),
driver=LocalStructureRunDriver(
structure_factory_fn=lambda: build_writer(
role=writer["role"],
goal=writer["goal"],
backstory=writer["backstory"],
)
),
parent_ids=[research_task.id],
)
for writer in WRITERS
])
end_task = team.add_task(
PromptTask(
'State "All Done!"',
parent_ids=[writer_task.id for writer_task in writer_tasks],
)
)

team.run()
Expand Down
181 changes: 163 additions & 18 deletions docs/griptape-framework/structures/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,35 @@ Let's build a simple workflow. Let's say, we want to write a story in a fantasy
from griptape.tasks import PromptTask
from griptape.structures import Workflow

workflow = Workflow()

world_task = PromptTask(
"Create a fictional world based on the following key words {{ keywords|join(', ') }}",
context={
"keywords": ["fantasy", "ocean", "tidal lock"]
},
id="world"
)

def character_task(task_id, character_name) -> PromptTask:
return PromptTask(
"Based on the following world description create a character named {{ name }}:\n{{ parent_outputs['world'] }}",
context={
"name": character_name
},
id=task_id
id=task_id,
parent_ids=["world"]
)

world_task = PromptTask(
"Create a fictional world based on the following key words {{ keywords|join(', ') }}",
context={
"keywords": ["fantasy", "ocean", "tidal lock"]
},
id="world"
)
workflow.add_task(world_task)
scotty_task = character_task("scotty", "Scotty")
annie_task = character_task("annie", "Annie")

story_task = PromptTask(
"Based on the following description of the world and characters, write a short story:\n{{ parent_outputs['world'] }}\n{{ parent_outputs['scotty'] }}\n{{ parent_outputs['annie'] }}",
id="story"
id="story",
parent_ids=["world", "scotty", "annie"]
)
workflow.add_task(story_task)

character_task_1 = character_task("scotty", "Scotty")
character_task_2 = character_task("annie", "Annie")

# Note the preserve_relationship flag. This ensures that world_task remains a parent of
# story_task so its output can be referenced in the story_task prompt.
workflow.insert_tasks(world_task, [character_task_1, character_task_2], story_task, preserve_relationship=True)
workflow = Workflow(tasks=[world_task, story_task, scotty_task, annie_task, story_task])

workflow.run()
```
Expand Down Expand Up @@ -147,3 +144,151 @@ workflow.run()
unity and harmony that can exist in diversity.
```

### Declarative vs Imperative Syntax

The above example showed how to create a workflow using the declarative syntax via the `parent_ids` init param, but there are a number of declarative and imperative options for you to choose between. There is no functional difference, they merely exist to allow you to structure your code as is most readable for your use case. Possibilities are illustrated below.

Declaratively specify parents (same as above example):

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

workflow = Workflow(
tasks=[
PromptTask("Name an animal", id="animal"),
PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective", parent_ids=["animal"]),
PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal", parent_ids=["adjective"]),
],
rules=[Rule("output a single lowercase word")]
)

workflow.run()
```

Declaratively specify children:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

workflow = Workflow(
tasks=[
PromptTask("Name an animal", id="animal", child_ids=["adjective"]),
PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective", child_ids=["new-animal"]),
PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal"),
],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```

Declaratively specifying a mix of parents and children:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

workflow = Workflow(
tasks=[
PromptTask("Name an animal", id="animal"),
PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective", parent_ids=["animal"], child_ids=["new-animal"]),
PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal"),
],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```

Imperatively specify parents:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

animal_task = PromptTask("Name an animal", id="animal")
adjective_task = PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective")
new_animal_task = PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal")

adjective_task.add_parent(animal_task)
new_animal_task.add_parent(adjective_task)

workflow = Workflow(
tasks=[animal_task, adjective_task, new_animal_task],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```

Imperatively specify children:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

animal_task = PromptTask("Name an animal", id="animal")
adjective_task = PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective")
new_animal_task = PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal")

animal_task.add_child(adjective_task)
adjective_task.add_child(new_animal_task)

workflow = Workflow(
tasks=[animal_task, adjective_task, new_animal_task],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```

Imperatively specify a mix of parents and children:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

animal_task = PromptTask("Name an animal", id="animal")
adjective_task = PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective")
new_animal_task = PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal")

adjective_task.add_parent(animal_task)
adjective_task.add_child(new_animal_task)

workflow = Workflow(
tasks=[animal_task, adjective_task, new_animal_task],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```

Or even mix imperative and declarative:

```python
from griptape.tasks import PromptTask
from griptape.structures import Workflow
from griptape.rules import Rule

animal_task = PromptTask("Name an animal", id="animal")
adjective_task = PromptTask("Describe {{ parent_outputs['animal'] }} with an adjective", id="adjective", parent_ids=["animal"])


new_animal_task = PromptTask("Name a {{ parent_outputs['adjective'] }} animal", id="new-animal")
new_animal_task.add_parent(adjective_task)

workflow = Workflow(
tasks=[animal_task, adjective_task, new_animal_task],
rules=[Rule("output a single lowercase word")],
)

workflow.run()
```
22 changes: 22 additions & 0 deletions griptape/structures/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,35 @@ def publish_event(self, event: BaseEvent, flush: bool = False) -> None:
def context(self, task: BaseTask) -> dict[str, Any]:
return {"args": self.execution_args, "structure": self}

def resolve_relationships(self) -> None:
task_by_id = {task.id: task for task in self.tasks}

for task in self.tasks:
# Ensure parents include this task as a child
for parent_id in task.parent_ids:
if parent_id not in task_by_id:
raise ValueError(f"Task with id {parent_id} doesn't exist.")
parent = task_by_id[parent_id]
if task.id not in parent.child_ids:
parent.child_ids.append(task.id)

# Ensure children include this task as a parent
for child_id in task.child_ids:
if child_id not in task_by_id:
raise ValueError(f"Task with id {child_id} doesn't exist.")
child = task_by_id[child_id]
if task.id not in child.parent_ids:
child.parent_ids.append(task.id)

def before_run(self) -> None:
self.publish_event(
StartStructureRunEvent(
structure_id=self.id, input_task_input=self.input_task.input, input_task_output=self.input_task.output
)
)

self.resolve_relationships()

def after_run(self) -> None:
self.publish_event(
FinishStructureRunEvent(
Expand Down
11 changes: 6 additions & 5 deletions griptape/structures/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ class Workflow(Structure):
def add_task(self, task: BaseTask) -> BaseTask:
task.preprocess(self)

if self.output_task:
self.output_task.child_ids.append(task.id)
task.parent_ids.append(self.output_task.id)

self.tasks.append(task)

return task
Expand Down Expand Up @@ -77,6 +73,7 @@ def insert_task(
if parent_task.id in child_task.parent_ids:
child_task.parent_ids.remove(parent_task.id)

last_parent_index = -1
for parent_task in parent_tasks:
# Link the new task to the parent task
if parent_task.id not in task.parent_ids:
Expand All @@ -85,7 +82,11 @@ def insert_task(
parent_task.child_ids.append(task.id)

parent_index = self.tasks.index(parent_task)
self.tasks.insert(parent_index + 1, task)
if parent_index > last_parent_index:
last_parent_index = parent_index

# Insert the new task once, just after the last parent task
self.tasks.insert(last_parent_index + 1, task)

return task

Expand Down
18 changes: 0 additions & 18 deletions griptape/tasks/actions_subtask.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,24 +172,6 @@ def actions_to_dicts(self) -> list[dict]:
def actions_to_json(self) -> str:
return json.dumps(self.actions_to_dicts())

def add_child(self, child: ActionsSubtask) -> ActionsSubtask:
if child.id not in self.child_ids:
self.child_ids.append(child.id)

if self.id not in child.parent_ids:
child.parent_ids.append(self.id)

return child

def add_parent(self, parent: ActionsSubtask) -> ActionsSubtask:
if parent.id not in self.parent_ids:
self.parent_ids.append(parent.id)

if self.id not in parent.child_ids:
parent.child_ids.append(self.id)

return parent

Comment on lines -175 to -192
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary to remove? I understand it's for consistency, but isn't that what overriding methods is good for? Changing behavior in subclasses?

Copy link
Contributor Author

@dylanholmes dylanholmes Jun 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not necessary to remove. I just thought it makes the logic easier to reason about.

I agree that overriding is for changing behavior in subclasses, but it can be abused. It seems unintuitive to change mutate objects passed in as arguments in a subclass only.

As an extreme example, imagine a class that has a method called sort which returns a list. It would be pretty strange if the base class returned a new list in sorted order, but a subclass sorted in-place and returned a reference to the same list.

That said, this case is not nearly that extreme, and I don't have the strongest opinion. I just gave the example as the intuition behind my position.

Let me know if this changes your opinion at all. I'll gladly revert if you prefer

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sold, thanks for explaining!

def __init_from_prompt(self, value: str) -> None:
thought_matches = re.findall(self.THOUGHT_PATTERN, value, re.MULTILINE)
actions_matches = re.findall(self.ACTIONS_PATTERN, value, re.DOTALL)
Expand Down
Loading
Loading