mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
22
autogpt_platform/backend/poetry.lock
generated
22
autogpt_platform/backend/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user