mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
fix: stream input guardrail, tool error dicts, and per-team token scoping (fixes #4446)
- _start_stream_impl now runs input-guardrail validation like chat()/achat(),
so start(stream=True)/iter_stream() no longer skip it
- a tool's own {"error": ...} return with no transient/denial marker flows back
as a normal tool result instead of raising a fatal ToolExecutionError, letting
the LLM self-correct
- PraisonAIAgents token summaries filter to the instance's own agent names,
preventing concurrent instances from leaking each other's usage/cost
Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
4d5e08fb1e
commit
1a677bf1c0
3 files changed
+68
-10
No files matched your search
@@ -4763,6 +4763,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"applied to streamed responses (iter_stream / stream=True). "
|
||||
"Use chat() for guardrail-validated output."
|
||||
)
|
||||
# The input-side guardrail has no streaming constraint (the full prompt
|
||||
# is known before the first token is requested, exactly like chat()),
|
||||
# so validate it here too. Otherwise start(stream=True)/iter_stream()
|
||||
# would silently skip the input guardrail that chat()/achat() enforce.
|
||||
if hasattr(self, '_validate_input_with_guardrail'):
|
||||
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
|
||||
if not _in_ok:
|
||||
logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}")
|
||||
yield f"[Input blocked by guardrail: {_in_err}]"
|
||||
return
|
||||
prompt = _in_prompt
|
||||
try:
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -886,12 +886,21 @@ class ToolExecutionMixin:
|
||||
# Strip the private control-plane tag before it can reach
|
||||
# the model or be re-surfaced as the tool's payload.
|
||||
result.pop("_praison_retryable", None)
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
if is_retryable:
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# A plain tool-authored {"error": ...} with none of the
|
||||
# transient/denial markers is the tool's own documented
|
||||
# way of reporting a recoverable failure (see
|
||||
# tools/shell_tools.py and the other bundled tools). It is
|
||||
# the tool's answer, not a framework-level crash — hand it
|
||||
# back as a normal tool result so the LLM can see it and
|
||||
# self-correct, instead of aborting the whole run.
|
||||
break
|
||||
else:
|
||||
# Success path - return the result
|
||||
break
|
||||
|
||||
@@ -2587,12 +2587,50 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
return False
|
||||
|
||||
def _scoped_token_summary(self) -> Dict[str, Any]:
|
||||
"""Return the global token summary filtered to this instance's agents.
|
||||
|
||||
The process-wide TokenCollector aggregates every agent in the process,
|
||||
so two concurrent PraisonAIAgents instances would otherwise read each
|
||||
other's tokens (a cross-tenant usage/cost leak). This instance's own
|
||||
agent names are a natural scoping key: the collector already breaks
|
||||
usage down by_agent, so we sum only the rows belonging to this team.
|
||||
"""
|
||||
summary = get_token_collector().get_session_summary()
|
||||
by_agent = summary.get("by_agent", {})
|
||||
own_names = {getattr(a, "name", None) for a in (self.agents or [])}
|
||||
own_names.discard(None)
|
||||
# No named agents to scope by (or nothing tracked yet): fall back to the
|
||||
# unfiltered summary rather than silently reporting zeros.
|
||||
if not own_names or not by_agent:
|
||||
return summary
|
||||
|
||||
scoped_by_agent = {
|
||||
name: metrics for name, metrics in by_agent.items() if name in own_names
|
||||
}
|
||||
totals = {
|
||||
"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0,
|
||||
"reasoning_tokens": 0, "audio_input_tokens": 0, "audio_output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for metrics in scoped_by_agent.values():
|
||||
for key in totals:
|
||||
totals[key] += metrics.get(key, 0)
|
||||
|
||||
return {
|
||||
"total_interactions": summary.get("total_interactions", 0),
|
||||
"total_tokens": totals["total_tokens"],
|
||||
"total_metrics": totals,
|
||||
"by_model": summary.get("by_model", {}),
|
||||
"by_agent": scoped_by_agent,
|
||||
}
|
||||
|
||||
def get_token_usage_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of token usage across all agents and tasks."""
|
||||
"""Get a summary of token usage across this team's agents and tasks."""
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
return get_token_collector().get_session_summary()
|
||||
return self._scoped_token_summary()
|
||||
|
||||
def get_detailed_token_report(self) -> Dict[str, Any]:
|
||||
"""Get a detailed token usage report."""
|
||||
@@ -2600,7 +2638,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
collector = get_token_collector()
|
||||
summary = collector.get_session_summary()
|
||||
summary = self._scoped_token_summary()
|
||||
recent = collector.get_recent_interactions(limit=20)
|
||||
|
||||
# Calculate cost estimates (example rates)
|
||||
@@ -2629,7 +2667,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
print("Token tracking not available")
|
||||
return
|
||||
|
||||
summary = get_token_collector().get_session_summary()
|
||||
summary = self._scoped_token_summary()
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("TOKEN USAGE SUMMARY")
|
||||
|
||||
Reference in new issue
Block a user