Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions src/praisonai-agents/praisonaiagents/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ def __init__(
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
retry: Optional[Union[bool, Dict[str, Any], 'RetryBackoffConfig']] = None, # Retry configuration with exponential backoff
reasoning_effort: Optional[str] = None, # Provider-portable reasoning effort: off|minimal|low|medium|high (Issue #4452)
**legacy_kwargs: Any, # Deprecated params (see _LEGACY_AGENT_PARAMS) consolidated into config objects
):
"""Initialize an Agent instance.
Expand Down Expand Up @@ -799,6 +800,10 @@ def __init__(
# LLMConfig(fallback_models=[...])), so accept it here without exposing
# it in the signature and seed the local below.
_cloned_fallback_models = legacy_kwargs.pop("fallback_models", None)
# `thinking_budget` is a backward-compatible alias for `reasoning_effort`
# (Issue #4452); accept it here (like fallback_models) without exposing
# it in the signature so the unknown-kwarg guard below does not reject it.
_thinking_budget_alias = legacy_kwargs.pop("thinking_budget", None)
_unknown = set(legacy_kwargs) - _legacy_defaults.keys()
if _unknown:
# Unknown kwargs are rejected rather than swallowed, so a typo can
Expand Down Expand Up @@ -1026,7 +1031,11 @@ def __init__(
planning_reasoning = False
policy = None
output_style = None
thinking_budget = None
# `thinking_budget` (popped above) is a backward-compatible alias for
# `reasoning_effort` (Issue #4452). Fold the legacy int budget / graded
# level into one internal effort value for the LLM request pipeline.
if reasoning_effort is None and _thinking_budget_alias is not None:
reasoning_effort = _thinking_budget_alias
skills_dirs = None

# ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2092,6 +2101,11 @@ def _any_output_mode_enabled():
'claude_memory': claude_memory,
**_retry_init_params,
}
# Forward the unified reasoning-effort control to every LLM-construction
# branch (Issue #4452). Only set when provided so unset stays a no-op and
# older dict/string branches that don't spread these kwargs are unaffected.
if reasoning_effort is not None:
self._llm_option_kwargs['reasoning_effort'] = reasoning_effort

# Panel (multi-model) descriptor: "panel:<name>" or {"provider": "panel"}.
# Resolved lazily into a PanelLLM; composes with the normal tool loop.
Expand Down Expand Up @@ -2213,7 +2227,14 @@ def _any_output_mode_enabled():
self._llm_init_params = llm_params
self._using_custom_llm = True
self.llm = model_name


# Thread the unified reasoning-effort control into whichever LLM-init
# params the branch chain above produced (Issue #4452), so it reaches the
# LLM request pipeline regardless of how the model was specified. Only set
# when provided and not already present, keeping unset a zero-cost no-op.
if reasoning_effort is not None and getattr(self, "_llm_init_params", None):
self._llm_init_params.setdefault('reasoning_effort', reasoning_effort)

# Store fallback models for resilience (defensive copy to avoid external mutations)
self.fallback_models = list(fallback_models) if fallback_models else []

Expand Down Expand Up @@ -2752,7 +2773,16 @@ def _any_output_mode_enabled():
self._auto_memory = auto_memory
self._policy = policy
self._output_style = output_style
self._thinking_budget = thinking_budget
# Backward-compatible: `thinking_budget` property mirrors the legacy int
# budget when supplied via the alias (Issue #4452); the unified effort is
# what actually drives the request pipeline via `_llm_init_params`.
self._thinking_budget = (
_thinking_budget_alias
if isinstance(_thinking_budget_alias, int)
else None
)
# Store the resolved unified reasoning-effort for session persistence.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
self._reasoning_effort = reasoning_effort

# Context management (lazy loaded for zero overhead when disabled)
# Smart default: auto-enable context when tools are present
Expand Down Expand Up @@ -3198,7 +3228,42 @@ def thinking_budget(self) -> Optional[int]:

@thinking_budget.setter
def thinking_budget(self, value: Optional[int]) -> None:
self._thinking_budget = value
# `thinking_budget` is a backward-compatible alias for the unified
# `reasoning_effort` control (Issue #4452). Setting it must route through
# the same request-pipeline sync as `reasoning_effort`, otherwise the
# CLI's `--thinking` (which assigns this property after construction)
# stays dormant — the value would be stored but never emitted.
self._thinking_budget = value if isinstance(value, int) else None
self.reasoning_effort = value

@property
def reasoning_effort(self) -> Optional[str]:
"""Unified, provider-portable reasoning-effort level (Issue #4452).

One of ``off|minimal|low|medium|high``. Core translates it to the
target provider's native parameter (OpenAI/xAI ``reasoning_effort``,
Anthropic/Gemini extended-thinking budget) on the request path.
"""
return getattr(self, "_reasoning_effort", None)

@reasoning_effort.setter
def reasoning_effort(self, value: Optional[str]) -> None:
self._reasoning_effort = value
# Keep the live LLM-init params in sync so a post-construction change
# still reaches the request pipeline (mirrors thinking_budget aliasing).
if getattr(self, "_llm_init_params", None) is not None:
if value is None:
self._llm_init_params.pop('reasoning_effort', None)
else:
self._llm_init_params['reasoning_effort'] = value
# If an LLM instance has already been built (lazy cache), update it too so
# a post-construction change still takes effect without a rebuild.
instance = getattr(self, "_llm_instance", None)
if instance is not None:
try:
instance.reasoning_effort = value
except Exception:
pass

@property
def total_cost(self) -> float:
Expand Down
22 changes: 22 additions & 0 deletions src/praisonai-agents/praisonaiagents/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,14 @@ def __init__(
self.max_reflect = extra_settings.get('max_reflect', 3)
self.min_reflect = extra_settings.get('min_reflect', 1)
self.reasoning_steps = extra_settings.get('reasoning_steps', False)
# Unified, provider-portable reasoning-effort control (Issue #4452).
# Accepts a graded level (off|minimal|low|medium|high) or a legacy
# ``thinking_budget`` int; both normalise to one internal value that is
# translated to each provider's native parameter in
# ``_build_completion_params``. ``None``/``off`` is a zero-overhead no-op.
self.reasoning_effort = extra_settings.get(
'reasoning_effort', extra_settings.get('thinking_budget')
)
self.metrics = extra_settings.get('metrics', False)
# Auto-detect XML tool format for known models, or allow manual override
self.xml_tool_format = extra_settings.get('xml_tool_format', 'auto')
Expand Down Expand Up @@ -5776,10 +5784,24 @@ def _build_completion_params(self, **override_params) -> Dict[str, Any]:
'max_tool_calls_per_turn', 'parallel_tool_calls', # Tool execution settings
'in_loop_compaction', 'clear_threshold_pct', 'compact_threshold_pct', # In-loop context management
'keep_recent_tool_results', # In-loop context management
'reasoning_effort', 'thinking_budget', # Reasoning-effort (translated below, Issue #4452)
]
for param in internal_params:
params.pop(param, None)

# Translate the unified reasoning-effort level to the target provider's
# native request parameter (Issue #4452): OpenAI/xAI reasoning models get
# ``reasoning_effort``, Anthropic/Gemini get an extended-thinking budget,
# and models with no reasoning control are left untouched. A per-call
# override wins over the instance-level setting; ``off``/unset is a no-op.
effort = override_params.get('reasoning_effort', self.reasoning_effort)
if effort is not None:
from ..thinking.effort import resolve_reasoning_params
reasoning_params = resolve_reasoning_params(effort, self.model)
# Don't clobber an explicit native param the caller already set.
for key, value in reasoning_params.items():
params.setdefault(key, value)

# Reasoning models (o1/o3/gpt-5.x) require max_completion_tokens and
# reject the legacy max_tokens parameter plus several sampling params.
# Normalize here (after override_params merge) so per-call overrides
Expand Down
28 changes: 26 additions & 2 deletions src/praisonai-agents/praisonaiagents/session/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ def to_dict(self) -> Dict[str, Any]:
}
if self.last_compaction is not None:
data["last_compaction"] = self.last_compaction.to_dict()
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source",
"reasoning_effort"):
if key in self.metadata:
data[key] = self.metadata[key]
return data
Expand Down Expand Up @@ -270,7 +271,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
# resume can recover the recorded model instead of silently reverting to
# the current default (Issue #3685). Existing metadata always wins.
metadata = dict(data.get("metadata") or {})
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source",
"reasoning_effort"):
if key not in metadata and data.get(key) is not None:
metadata[key] = data[key]
return cls(
Expand Down Expand Up @@ -1449,6 +1451,28 @@ def get_session_model(self, session_id: str) -> Optional[str]:
return recorded
return None

def get_session_reasoning_effort(self, session_id: str) -> Optional[str]:
"""Return the reasoning effort a session was last run with (Issue #4452).

Mirrors :meth:`get_session_model`: resolves the session-level
``reasoning_effort`` recorded in metadata so a resume (``--continue`` /
``--session``) can restore the graded effort alongside the model. Returns
``None`` when none was recorded, so a caller falls back to the default /
per-invocation value.
Comment on lines +1454 to +1461

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Effort is not resumed

When a session using reasoning_effort is continued, the normal metadata flush does not write this field and the resume path restores only the model. The new getter is therefore never given persisted production data or called during resume, so the session silently uses the current or default effort.

Knowledge Base Used:

"""
try:
session = self._read_session_fresh(session_id)
except Exception:
return None
effort = session.metadata.get("reasoning_effort")
if isinstance(effort, str) and effort:
return effort
for message in reversed(session.messages):
recorded = (message.metadata or {}).get("reasoning_effort")
if isinstance(recorded, str) and recorded:
return recorded
return None

def set_agent_info(
self,
session_id: str,
Expand Down
20 changes: 14 additions & 6 deletions src/praisonai-agents/praisonaiagents/thinking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
# Compute an adaptive token budget for a given task complexity (0.0-1.0)
tokens = budget.get_tokens_for_complexity(0.8)

# Note: `agent.thinking_budget` stores this object on the agent as a hint,
# but the core Agent request pipeline does not yet apply it automatically.
# To influence step-by-step reasoning today, use the built-in
# `reasoning_steps` option instead:
agent = Agent(instructions="...", reasoning_steps=True)
# `agent.thinking_budget` is a backward-compatible alias for the unified
# `reasoning_effort` control. Prefer the graded level directly, which the
# core request pipeline translates to each provider's native parameter
# (OpenAI/xAI `reasoning_effort`, Anthropic/Gemini extended-thinking budget):
agent = Agent(instructions="...", reasoning_effort="high")
"""

__all__ = [
Expand All @@ -40,6 +40,10 @@
# Tracking
"ThinkingUsage",
"ThinkingTracker",
# Reasoning-effort translation (provider-portable)
"resolve_reasoning_params",
"normalize_effort",
"EFFORT_LEVELS",
]


Expand All @@ -60,5 +64,9 @@ def __getattr__(name: str):
if name == "ThinkingTracker":
from .tracker import ThinkingTracker
return ThinkingTracker


if name in ("resolve_reasoning_params", "normalize_effort", "EFFORT_LEVELS"):
from . import effort
return getattr(effort, name)

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
126 changes: 126 additions & 0 deletions src/praisonai-agents/praisonaiagents/thinking/effort.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
Reasoning-effort translation for PraisonAI Agents.

A single, graded, provider-portable reasoning-effort control that resolves to
each provider's native request parameter:

- OpenAI o-series / GPT-5 / xAI reasoning models -> native ``reasoning_effort``
(``minimal|low|medium|high``).
- Anthropic / Gemini extended-thinking models -> a ``thinking`` token budget.
- Models without a reasoning control -> nothing (silently ignored,
backward-compatible).

Zero overhead when unused: ``off``/``None`` resolves to an empty dict and the
helper is only imported on the request path when an effort is actually set.
"""

from typing import Any, Dict, Optional

# Canonical graded levels shared with the CLI surface
# (``praisonai_code.cli.features.thinking.THINKING_LEVELS``).
EFFORT_LEVELS = ("off", "minimal", "low", "medium", "high")

# Native ``reasoning_effort`` only accepts these; ``off`` is a no-op.
_NATIVE_EFFORT = {"minimal", "low", "medium", "high"}

# Extended-thinking token budgets for Anthropic/Gemini, mirroring the CLI's
# ``THINKING_BUDGET_MAP`` so a level means the same thing on every surface.
_EFFORT_BUDGET_MAP: Dict[str, Optional[int]] = {
"off": None,
"minimal": 2000,
"low": 4000,
"medium": 8000,
"high": 16000,
}

# Inverse of the budget map: lets a legacy ``thinking_budget`` int be normalised
# back to the nearest graded level so both surfaces share one internal value.
_BUDGET_EFFORT_PAIRS = sorted(
((tokens, level) for level, tokens in _EFFORT_BUDGET_MAP.items() if tokens),
key=lambda pair: pair[0],
)


def normalize_effort(value: Any) -> Optional[str]:
"""Normalise a reasoning-effort value to a canonical level or ``None``.

Accepts a graded string (``off|minimal|low|medium|high``, case-insensitive)
or a legacy ``thinking_budget`` int (mapped to the nearest level). Unknown
or unset values return ``None`` (treated as "no reasoning control").
"""
if value is None:
return None
if isinstance(value, bool):
# Guard against ``True``/``False`` sneaking in via ``int`` handling.
return "medium" if value else None
if isinstance(value, str):
level = value.strip().lower()
return level if level in EFFORT_LEVELS else None
if isinstance(value, int):
if value <= 0:
return None
# Map a token budget to the smallest level whose budget covers it.
for tokens, level in _BUDGET_EFFORT_PAIRS:
if value <= tokens:
return level
return "high"
return None


def _is_native_effort_model(model: str) -> bool:
"""OpenAI o-series / GPT-5 / xAI reasoning models take native effort."""
from ..llm.model_capabilities import is_reasoning_model

name = (model or "").lower()
if name.startswith("xai/") or "grok" in name:
return True
return is_reasoning_model(model)


def _is_extended_thinking_model(model: str) -> bool:
"""Anthropic / Gemini models expose an extended-thinking token budget."""
name = (model or "").lower()
return (
"claude" in name
or "anthropic" in name
or "gemini" in name
)


def resolve_reasoning_params(effort: Any, model: str) -> Dict[str, Any]:
"""Translate a unified reasoning-effort level to provider-native kwargs.

Args:
effort: A graded level (``off|minimal|low|medium|high``) or a legacy
``thinking_budget`` int; anything else is treated as unset.
model: The target model name (with or without provider prefix).

Returns:
A dict of native request params to merge into the completion call:
``{"reasoning_effort": <level>}`` for OpenAI/xAI reasoning models,
``{"thinking": {"type": "enabled", "budget_tokens": <int>}}`` for
Anthropic/Gemini extended-thinking models, or ``{}`` when the effort is
off/unset or the model has no reasoning control.
"""
level = normalize_effort(effort)
if level is None or level == "off":
return {}

# Anthropic / Gemini expose an extended-thinking token budget. Checked first
# because these families can also match the generic reasoning-model
# classifier, but their native control is the thinking budget, not
# ``reasoning_effort``.
if _is_extended_thinking_model(model):
budget = _EFFORT_BUDGET_MAP.get(level)
if budget:
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
return {}

# OpenAI o-series / GPT-5 / xAI reasoning models take native reasoning_effort.
if _is_native_effort_model(model):
if level in _NATIVE_EFFORT:
return {"reasoning_effort": level}
return {}

# No known reasoning control for this model: silently ignore.
return {}
Loading
Loading