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:
praisonai-triage-agent[bot]andMervin Praison committed 2026-08-27 11:19:04 +00:00
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")