feat(blocks): add plural outputs where blocks yield singular values in loops (#10304)

## Summary

This PR adds missing plural output versions to blocks that yield
individual items in loops but don't provide the complete collection,
enabling both individual item access (for iteration) and complete
collection access (for aggregate operations).

## Changes

### GitHub Blocks (existing)
- **GithubListPullRequestsBlock**: Added `pull_requests` output
alongside existing `pull_request`
- **GithubListPRReviewersBlock**: Added `reviewers` output alongside
existing `reviewer`

### Additional Blocks (added in this PR)
- **GetRedditPostsBlock**: Added `posts` output for complete list of
Reddit posts
- **ReadRSSFeedBlock**: Added `entries` output for complete list of RSS
entries
- **AddMemoryBlock**: Added `results` output for complete list of memory
operation results

## Pattern Applied

The pattern ensures blocks provide both:
```python
# Complete collection first
yield "plural_output", all_items

# Then individual items for iteration
for item in all_items:
    yield "singular_output", item
```

## Testing
- Updated test outputs to include plural versions
- All blocks maintain backward compatibility with existing singular
outputs
- `poetry run format` -  Passed
- `poetry run test` -  Blocks validated

## Benefits
- **Iteration**: Users can still iterate over individual items as before
- **Aggregation**: Users can now access complete collections for
operations like counting, filtering, or batch processing
- **Compatibility**: Existing workflows continue to work unchanged

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Toran Bruce Richards
2025-07-03 21:06:31 +01:00
committed by GitHub
parent 4744e0f6b1
commit 9fecbe2a31
6 changed files with 182 additions and 26 deletions

View File

@@ -498,6 +498,9 @@ class GithubListIssuesBlock(Block):
issue: IssueItem = SchemaField(
title="Issue", description="Issues with their title and URL"
)
issues: list[IssueItem] = SchemaField(
description="List of issues with their title and URL"
)
error: str = SchemaField(description="Error message if listing issues failed")
def __init__(self):
@@ -513,13 +516,22 @@ class GithubListIssuesBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"issues",
[
{
"title": "Issue 1",
"url": "https://github.com/owner/repo/issues/1",
}
],
),
(
"issue",
{
"title": "Issue 1",
"url": "https://github.com/owner/repo/issues/1",
},
)
),
],
test_mock={
"list_issues": lambda *args, **kwargs: [
@@ -551,10 +563,12 @@ class GithubListIssuesBlock(Block):
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
for issue in await self.list_issues(
issues = await self.list_issues(
credentials,
input_data.repo_url,
):
)
yield "issues", issues
for issue in issues:
yield "issue", issue

View File

@@ -31,7 +31,12 @@ class GithubListPullRequestsBlock(Block):
pull_request: PRItem = SchemaField(
title="Pull Request", description="PRs with their title and URL"
)
error: str = SchemaField(description="Error message if listing issues failed")
pull_requests: list[PRItem] = SchemaField(
description="List of pull requests with their title and URL"
)
error: str = SchemaField(
description="Error message if listing pull requests failed"
)
def __init__(self):
super().__init__(
@@ -46,13 +51,22 @@ class GithubListPullRequestsBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"pull_requests",
[
{
"title": "Pull request 1",
"url": "https://github.com/owner/repo/pull/1",
}
],
),
(
"pull_request",
{
"title": "Pull request 1",
"url": "https://github.com/owner/repo/pull/1",
},
)
),
],
test_mock={
"list_prs": lambda *args, **kwargs: [
@@ -88,6 +102,7 @@ class GithubListPullRequestsBlock(Block):
credentials,
input_data.repo_url,
)
yield "pull_requests", pull_requests
for pr in pull_requests:
yield "pull_request", pr
@@ -460,6 +475,9 @@ class GithubListPRReviewersBlock(Block):
title="Reviewer",
description="Reviewers with their username and profile URL",
)
reviewers: list[ReviewerItem] = SchemaField(
description="List of reviewers with their username and profile URL"
)
error: str = SchemaField(
description="Error message if listing reviewers failed"
)
@@ -477,13 +495,22 @@ class GithubListPRReviewersBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"reviewers",
[
{
"username": "reviewer1",
"url": "https://github.com/reviewer1",
}
],
),
(
"reviewer",
{
"username": "reviewer1",
"url": "https://github.com/reviewer1",
},
)
),
],
test_mock={
"list_reviewers": lambda *args, **kwargs: [
@@ -516,10 +543,12 @@ class GithubListPRReviewersBlock(Block):
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
for reviewer in await self.list_reviewers(
reviewers = await self.list_reviewers(
credentials,
input_data.pr_url,
):
)
yield "reviewers", reviewers
for reviewer in reviewers:
yield "reviewer", reviewer

View File

@@ -31,6 +31,9 @@ class GithubListTagsBlock(Block):
tag: TagItem = SchemaField(
title="Tag", description="Tags with their name and file tree browser URL"
)
tags: list[TagItem] = SchemaField(
description="List of tags with their name and file tree browser URL"
)
error: str = SchemaField(description="Error message if listing tags failed")
def __init__(self):
@@ -46,13 +49,22 @@ class GithubListTagsBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"tags",
[
{
"name": "v1.0.0",
"url": "https://github.com/owner/repo/tree/v1.0.0",
}
],
),
(
"tag",
{
"name": "v1.0.0",
"url": "https://github.com/owner/repo/tree/v1.0.0",
},
)
),
],
test_mock={
"list_tags": lambda *args, **kwargs: [
@@ -93,6 +105,7 @@ class GithubListTagsBlock(Block):
credentials,
input_data.repo_url,
)
yield "tags", tags
for tag in tags:
yield "tag", tag
@@ -114,6 +127,9 @@ class GithubListBranchesBlock(Block):
title="Branch",
description="Branches with their name and file tree browser URL",
)
branches: list[BranchItem] = SchemaField(
description="List of branches with their name and file tree browser URL"
)
error: str = SchemaField(description="Error message if listing branches failed")
def __init__(self):
@@ -129,13 +145,22 @@ class GithubListBranchesBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"branches",
[
{
"name": "main",
"url": "https://github.com/owner/repo/tree/main",
}
],
),
(
"branch",
{
"name": "main",
"url": "https://github.com/owner/repo/tree/main",
},
)
),
],
test_mock={
"list_branches": lambda *args, **kwargs: [
@@ -176,6 +201,7 @@ class GithubListBranchesBlock(Block):
credentials,
input_data.repo_url,
)
yield "branches", branches
for branch in branches:
yield "branch", branch
@@ -199,6 +225,9 @@ class GithubListDiscussionsBlock(Block):
discussion: DiscussionItem = SchemaField(
title="Discussion", description="Discussions with their title and URL"
)
discussions: list[DiscussionItem] = SchemaField(
description="List of discussions with their title and URL"
)
error: str = SchemaField(
description="Error message if listing discussions failed"
)
@@ -217,13 +246,22 @@ class GithubListDiscussionsBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"discussions",
[
{
"title": "Discussion 1",
"url": "https://github.com/owner/repo/discussions/1",
}
],
),
(
"discussion",
{
"title": "Discussion 1",
"url": "https://github.com/owner/repo/discussions/1",
},
)
),
],
test_mock={
"list_discussions": lambda *args, **kwargs: [
@@ -279,6 +317,7 @@ class GithubListDiscussionsBlock(Block):
input_data.repo_url,
input_data.num_discussions,
)
yield "discussions", discussions
for discussion in discussions:
yield "discussion", discussion
@@ -300,6 +339,9 @@ class GithubListReleasesBlock(Block):
title="Release",
description="Releases with their name and file tree browser URL",
)
releases: list[ReleaseItem] = SchemaField(
description="List of releases with their name and file tree browser URL"
)
error: str = SchemaField(description="Error message if listing releases failed")
def __init__(self):
@@ -315,13 +357,22 @@ class GithubListReleasesBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"releases",
[
{
"name": "v1.0.0",
"url": "https://github.com/owner/repo/releases/tag/v1.0.0",
}
],
),
(
"release",
{
"name": "v1.0.0",
"url": "https://github.com/owner/repo/releases/tag/v1.0.0",
},
)
),
],
test_mock={
"list_releases": lambda *args, **kwargs: [
@@ -357,6 +408,7 @@ class GithubListReleasesBlock(Block):
credentials,
input_data.repo_url,
)
yield "releases", releases
for release in releases:
yield "release", release
@@ -1041,6 +1093,9 @@ class GithubListStargazersBlock(Block):
title="Stargazer",
description="Stargazers with their username and profile URL",
)
stargazers: list[StargazerItem] = SchemaField(
description="List of stargazers with their username and profile URL"
)
error: str = SchemaField(
description="Error message if listing stargazers failed"
)
@@ -1058,13 +1113,22 @@ class GithubListStargazersBlock(Block):
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"stargazers",
[
{
"username": "octocat",
"url": "https://github.com/octocat",
}
],
),
(
"stargazer",
{
"username": "octocat",
"url": "https://github.com/octocat",
},
)
),
],
test_mock={
"list_stargazers": lambda *args, **kwargs: [
@@ -1104,5 +1168,6 @@ class GithubListStargazersBlock(Block):
credentials,
input_data.repo_url,
)
yield "stargazers", stargazers
for stargazer in stargazers:
yield "stargazer", stargazer

View File

@@ -77,6 +77,9 @@ class AddMemoryBlock(Block, Mem0Base):
class Output(BlockSchema):
action: str = SchemaField(description="Action of the operation")
memory: str = SchemaField(description="Memory created")
results: list[dict[str, str]] = SchemaField(
description="List of all results from the operation"
)
error: str = SchemaField(description="Error message if operation fails")
def __init__(self):
@@ -104,8 +107,10 @@ class AddMemoryBlock(Block, Mem0Base):
},
],
test_output=[
("results", [{"event": "CREATED", "memory": "test memory"}]),
("action", "CREATED"),
("memory", "test memory"),
("results", [{"event": "CREATED", "memory": "test memory"}]),
("action", "CREATED"),
("memory", "test memory"),
],
@@ -150,8 +155,11 @@ class AddMemoryBlock(Block, Mem0Base):
**params,
)
if len(result.get("results", [])) > 0:
for result in result.get("results", []):
results = result.get("results", [])
yield "results", results
if len(results) > 0:
for result in results:
yield "action", result["event"]
yield "memory", result["memory"]
else:

View File

@@ -96,6 +96,7 @@ class GetRedditPostsBlock(Block):
class Output(BlockSchema):
post: RedditPost = SchemaField(description="Reddit post")
posts: list[RedditPost] = SchemaField(description="List of all Reddit posts")
def __init__(self):
super().__init__(
@@ -128,6 +129,23 @@ class GetRedditPostsBlock(Block):
id="id2", subreddit="subreddit", title="title2", body="body2"
),
),
(
"posts",
[
RedditPost(
id="id1",
subreddit="subreddit",
title="title1",
body="body1",
),
RedditPost(
id="id2",
subreddit="subreddit",
title="title2",
body="body2",
),
],
),
],
test_mock={
"get_posts": lambda input_data, credentials: [
@@ -150,6 +168,7 @@ class GetRedditPostsBlock(Block):
self, input_data: Input, *, credentials: RedditCredentials, **kwargs
) -> BlockOutput:
current_time = datetime.now(tz=timezone.utc)
all_posts = []
for post in self.get_posts(input_data=input_data, credentials=credentials):
if input_data.last_minutes:
post_datetime = datetime.fromtimestamp(
@@ -162,12 +181,16 @@ class GetRedditPostsBlock(Block):
if input_data.last_post and post.id == input_data.last_post:
break
yield "post", RedditPost(
reddit_post = RedditPost(
id=post.id,
subreddit=input_data.subreddit,
title=post.title,
body=post.selftext,
)
all_posts.append(reddit_post)
yield "post", reddit_post
yield "posts", all_posts
class PostRedditCommentBlock(Block):

View File

@@ -40,6 +40,7 @@ class ReadRSSFeedBlock(Block):
class Output(BlockSchema):
entry: RSSEntry = SchemaField(description="The RSS item")
entries: list[RSSEntry] = SchemaField(description="List of all RSS entries")
def __init__(self):
super().__init__(
@@ -66,6 +67,21 @@ class ReadRSSFeedBlock(Block):
categories=["Technology", "News"],
),
),
(
"entries",
[
RSSEntry(
title="Example RSS Item",
link="https://example.com/article",
description="This is an example RSS item description.",
pub_date=datetime(
2023, 6, 23, 12, 30, 0, tzinfo=timezone.utc
),
author="John Doe",
categories=["Technology", "News"],
),
],
),
],
test_mock={
"parse_feed": lambda *args, **kwargs: {
@@ -96,21 +112,22 @@ class ReadRSSFeedBlock(Block):
keep_going = input_data.run_continuously
feed = self.parse_feed(input_data.rss_url)
all_entries = []
for entry in feed["entries"]:
pub_date = datetime(*entry["published_parsed"][:6], tzinfo=timezone.utc)
if pub_date > start_time:
yield (
"entry",
RSSEntry(
title=entry["title"],
link=entry["link"],
description=entry.get("summary", ""),
pub_date=pub_date,
author=entry.get("author", ""),
categories=[tag["term"] for tag in entry.get("tags", [])],
),
rss_entry = RSSEntry(
title=entry["title"],
link=entry["link"],
description=entry.get("summary", ""),
pub_date=pub_date,
author=entry.get("author", ""),
categories=[tag["term"] for tag in entry.get("tags", [])],
)
all_entries.append(rss_entry)
yield "entry", rss_entry
yield "entries", all_entries
await asyncio.sleep(input_data.polling_rate)