feat(backend): Add cost on node & graph execution stats (#9520)

<!-- Clearly explain the need for these changes: -->
We want the agent run data to be accurate, which means we need to
collect it in the right place and with the correct data

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->
- Updates email templates
- Updates how we collect the data for the agent run event

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Run agents and read the email we get

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
This commit is contained in:
Zamil Majdy
2025-02-27 16:39:06 +07:00
committed by GitHub
parent d7cdf751a8
commit c1b12d4a12
7 changed files with 188 additions and 79 deletions

View File

@@ -39,7 +39,7 @@ class AgentRunData(BaseNotificationData):
execution_time: float
node_count: int = Field(..., description="Number of nodes executed")
graph_id: str
outputs: dict[str, Any] = Field(..., description="Outputs of the agent")
outputs: list[dict[str, Any]] = Field(..., description="Outputs of the agent")
class ZeroBalanceData(BaseNotificationData):

View File

@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Generator, Optional, TypeVar, cast
from redis.lock import Lock as RedisLock
from backend.blocks.basic import AgentOutputBlock
from backend.data.notifications import (
AgentRunData,
NotificationEventDTO,
@@ -115,7 +116,6 @@ ExecutionStream = Generator[NodeExecutionEntry, None, None]
def execute_node(
db_client: "DatabaseManager",
creds_manager: IntegrationCredentialsManager,
notification_service: "NotificationManager",
data: NodeExecutionEntry,
execution_stats: dict[str, Any] | None = None,
) -> ExecutionStream:
@@ -201,6 +201,7 @@ def execute_node(
extra_exec_kwargs[field_name] = credentials
output_size = 0
cost = 0
try:
# Charge the user for the execution before running the block.
cost = db_client.spend_credits(data)
@@ -227,21 +228,6 @@ def execute_node(
# Update execution status and spend credits
update_execution(ExecutionStatus.COMPLETED)
event = NotificationEventDTO(
user_id=user_id,
type=NotificationType.AGENT_RUN,
data=AgentRunData(
outputs=outputs,
agent_name=node_block.name,
credits_used=cost,
execution_time=0,
graph_id=graph_id,
node_count=1,
).model_dump(),
)
logger.info(f"Sending notification for {event}")
notification_service.queue_notification(event)
except Exception as e:
error_msg = str(e)
@@ -273,6 +259,7 @@ def execute_node(
execution_stats.update(node_block.execution_stats)
execution_stats["input_size"] = input_size
execution_stats["output_size"] = output_size
execution_stats["cost"] = cost
def _enqueue_next_nodes(
@@ -582,7 +569,6 @@ class Executor:
for execution in execute_node(
db_client=cls.db_client,
creds_manager=cls.creds_manager,
notification_service=cls.notification_service,
data=node_exec,
execution_stats=stats,
):
@@ -658,6 +644,49 @@ class Executor:
)
cls.db_client.send_execution_update(result)
metadata = cls.db_client.get_graph_metadata(
graph_exec.graph_id, graph_exec.graph_version
)
assert metadata is not None
outputs = cls.db_client.get_execution_results(graph_exec.graph_exec_id)
# Collect named outputs as a list of dictionaries
named_outputs = []
for output in outputs:
if output.block_id == AgentOutputBlock().id:
# Create a dictionary for this named output
named_output = {
# Include the name as a field in each output
"name": (
output.output_data["name"][0]
if isinstance(output.output_data["name"], list)
else output.output_data["name"]
)
}
# Add all other fields
for key, value in output.output_data.items():
if key != "name":
named_output[key] = value
named_outputs.append(named_output)
event = NotificationEventDTO(
user_id=graph_exec.user_id,
type=NotificationType.AGENT_RUN,
data=AgentRunData(
outputs=named_outputs,
agent_name=metadata.name,
credits_used=exec_stats["cost"],
execution_time=timing_info.wall_time,
graph_id=graph_exec.graph_id,
node_count=exec_stats["node_count"],
).model_dump(),
)
logger.info(f"Sending notification for {event}")
get_notification_service().queue_notification(event)
@classmethod
@time_measured
def _on_graph_execution(
@@ -677,6 +706,7 @@ class Executor:
"nodes_walltime": 0,
"nodes_cputime": 0,
"node_count": 0,
"cost": 0,
}
error = None
finished = False
@@ -714,6 +744,7 @@ class Executor:
exec_stats["node_count"] += 1
exec_stats["nodes_cputime"] += result.get("cputime", 0)
exec_stats["nodes_walltime"] += result.get("walltime", 0)
exec_stats["cost"] += result.get("cost", 0)
return callback

View File

@@ -1,11 +1,11 @@
{# Agent Run #}
{# Template variables:
data.name: the name of the agent
data.agent_name: the name of the agent
data.credits_used: the number of credits used by the agent
data.node_count: the number of nodes the agent ran on
data.execution_time: the time it took to run the agent
data.graph_id: the id of the graph the agent ran on
data.outputs: the dict[str, Any] of outputs of the agent
data.outputs: the list of outputs of the agent
#}
<p style="
font-family: 'Poppins', sans-serif;
@@ -15,7 +15,7 @@ data.outputs: the dict[str, Any] of outputs of the agent
margin-top: 0;
margin-bottom: 10px;
">
Hi,
Your agent, <strong>{{ data.agent_name }}</strong>, has completed its run!
</p>
<p style="
font-family: 'Poppins', sans-serif;
@@ -23,53 +23,74 @@ data.outputs: the dict[str, Any] of outputs of the agent
font-size: 16px;
line-height: 165%;
margin-top: 0;
margin-bottom: 10px;
margin-bottom: 20px;
padding-left: 20px;
">
We've run your agent {{ data.name }} and it took {{ data.execution_time }} seconds to complete.
<p style="margin-bottom: 10px;"><strong>Time Taken:</strong> {{ data.execution_time | int }} seconds</p>
<p style="margin-bottom: 10px;"><strong>Nodes Used:</strong> {{ data.node_count }}</p>
<p style="margin-bottom: 10px;"><strong>Cost:</strong> ${{ "{:.2f}".format((data.credits_used|float)/100) }}</p>
</p>
<p style="
{% if data.outputs and data.outputs|length > 0 %}
<div style="
margin-left: 15px;
margin-bottom: 20px;
">
<p style="
font-family: 'Poppins', sans-serif;
color: #070629;
font-weight: 600;
font-size: 16px;
line-height: 165%;
margin-top: 0;
margin-bottom: 10px;
">
It ran on {{ data.node_count }} nodes and used {{ data.credits_used }} credits.
</p>
<ul style="
font-family: 'Poppins', sans-serif;
color: #070629;
font-size: 16px;
line-height: 165%;
margin-top: 0;
margin-bottom: 10px;
">
It output the following:
{# jinja2 list iteration thorugh data.outputs #}
{% for key, value in data.outputs.items() %}
<li>{{ key }}: {{ value }}</li>
Results:
</p>
{% for output in data.outputs %}
<div style="
margin-left: 15px;
margin-bottom: 15px;
">
<p style="
font-family: 'Poppins', sans-serif;
color: #5D23BB;
font-weight: 500;
font-size: 16px;
margin-top: 0;
margin-bottom: 8px;
">
{{ output.name }}
</p>
{% for key, value in output.items() %}
{% if key != 'name' %}
<div style="
margin-left: 15px;
background-color: #f5f5ff;
padding: 8px 12px;
border-radius: 4px;
font-family: 'Roboto Mono', monospace;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
overflow-x: auto;
margin-top: 5px;
margin-bottom: 10px;
line-height: 1.4;
">
{% if value is iterable and value is not string %}
{% if value|length == 1 %}
{{ value[0] }}
{% else %}
[{% for item in value %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}]
{% endif %}
{% else %}
{{ value }}
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</ul>
<p style="
font-family: 'Poppins', sans-serif;
color: #070629;
font-size: 16px;
line-height: 165%;
margin-top: 0;
margin-bottom: 10px;
">
Your feedback has been instrumental in shaping AutoGPT, and we couldn't have
done it without you. We look forward to continuing this journey together as we
bring AI-powered automation to the world.
</p>
<p style="
font-family: 'Poppins', sans-serif;
color: #070629;
font-size: 16px;
line-height: 165%;
margin-top: 0;
margin-bottom: 0;
">
Thank you again for your time and support.
</p>
</div>
{% endif %}

View File

@@ -227,19 +227,10 @@
<table class="ml-8 wrapper" border="0" cellspacing="0" cellpadding="0"
style="color: #070629; text-align: left;">
<tr>
<td class="col mobile-center" align="center" width="80">
<img
src="https://storage.mlcdn.com/account_image/597379/68W8w94Zwl52yQyrKdFERRquu2CivAcn17ST22HF.jpg"
border="0" alt="" width="80" class="avatar"
style="display: inline-block; max-width: 80px; border-radius: 80px;">
</td>
<td class="col" width="30" height="30" style="line-height: 30px;"></td>
<td class="col center mobile-center" align>
<p
style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 0;">
John Ababseh<br>Product Manager<br>
<a href="mailto:john.ababseh@agpt.co" target="_blank"
style="color: #4285F4; font-weight: normal; font-style: normal; text-decoration: underline;">john.ababseh@agpt.co</a>
Thank you for being a part of the AutoGPT community! Join the conversation on our Discord <a href="https://discord.gg/autogpt" style="color: #4285F4; text-decoration: underline;">here</a> and share your thoughts with us anytime.
</p>
</td>
</tr>

View File

@@ -1,6 +1,7 @@
import logging
import bleach
from bleach.css_sanitizer import CSSSanitizer
from jinja2 import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
from markupsafe import Markup
@@ -8,14 +9,57 @@ from markupsafe import Markup
logger = logging.getLogger(__name__)
def format_filter_for_jinja2(value, format_string=None):
if format_string:
return format_string % float(value)
return value
class TextFormatter:
def __init__(self):
self.env = SandboxedEnvironment(loader=BaseLoader(), autoescape=True)
self.env.filters.clear()
self.env.tests.clear()
self.env.globals.clear()
self.allowed_tags = ["p", "b", "i", "u", "ul", "li", "br", "strong", "em"]
# Instead of clearing all filters, just remove potentially unsafe ones
unsafe_filters = ["pprint", "urlize", "xmlattr", "tojson"]
for f in unsafe_filters:
if f in self.env.filters:
del self.env.filters[f]
self.env.filters["format"] = format_filter_for_jinja2
# Define allowed CSS properties
allowed_css_properties = [
"font-family",
"color",
"font-size",
"line-height",
"margin-top",
"margin-bottom",
"margin-left",
"margin-right",
"background-color",
"padding",
"border-radius",
"font-weight",
"text-align",
]
self.css_sanitizer = CSSSanitizer(allowed_css_properties=allowed_css_properties)
self.allowed_tags = [
"p",
"b",
"i",
"u",
"ul",
"li",
"br",
"strong",
"em",
"div",
"span",
]
self.allowed_attributes = {"*": ["style", "class"]}
def format_string(self, template_str: str, values=None, **kwargs) -> str:
@@ -37,17 +81,19 @@ class TextFormatter:
# First render the content template
content = self.format_string(content_template, data, **kwargs)
# Clean the HTML but don't escape it
# Clean the HTML + CSS but don't escape it
clean_content = bleach.clean(
content,
tags=self.allowed_tags,
attributes=self.allowed_attributes,
css_sanitizer=self.css_sanitizer,
strip=True,
)
# Mark the cleaned HTML as safe using Markup
safe_content = Markup(clean_content)
# Render subject
rendered_subject_template = self.format_string(subject_template, data, **kwargs)
# Create new env just for HTML template

View File

@@ -395,6 +395,7 @@ files = [
]
[package.dependencies]
tinycss2 = {version = ">=1.1.0,<1.5", optional = true, markers = "extra == \"css\""}
webencodings = "*"
[package.extras]
@@ -5073,6 +5074,25 @@ files = [
doc = ["reno", "sphinx"]
test = ["pytest", "tornado (>=4.5)", "typeguard"]
[[package]]
name = "tinycss2"
version = "1.4.0"
description = "A tiny CSS parser"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289"},
{file = "tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7"},
]
[package.dependencies]
webencodings = ">=0.4"
[package.extras]
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["pytest", "ruff"]
[[package]]
name = "todoist-api-python"
version = "2.1.7"
@@ -6070,4 +6090,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "a903827e644f4e8973e8d07a7f4e69cd57d1567523e24b92bc7bffa3d6f045d7"
content-hash = "7d52ef1c6567900f7f1e079b2d317b861330bef797573221277f04b1981d0e05"

View File

@@ -13,7 +13,7 @@ aio-pika = "^9.5.4"
anthropic = "^0.45.2"
apscheduler = "^3.11.0"
autogpt-libs = { path = "../autogpt_libs", develop = true }
bleach = "^6.2.0"
bleach = {extras = ["css"], version = "^6.2.0"}
click = "^8.1.7"
cryptography = "^43.0"
discord-py = "^2.4.0"