mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c3172ab4 | ||
|
|
f007958dfb | ||
|
|
8c83470a3a | ||
|
|
a88399456a | ||
|
|
0e02c871f2 | ||
|
|
c5879fa54b |
No files matched your search
@@ -87,6 +87,10 @@ def __getattr__(name):
|
||||
from .model_capabilities import supports_streaming_with_tools
|
||||
_lazy_cache[name] = supports_streaming_with_tools
|
||||
return supports_streaming_with_tools
|
||||
elif name == "max_output_tokens":
|
||||
from .model_capabilities import max_output_tokens
|
||||
_lazy_cache[name] = max_output_tokens
|
||||
return max_output_tokens
|
||||
elif name == "ModelRouter":
|
||||
from .model_router import ModelRouter
|
||||
_lazy_cache[name] = ModelRouter
|
||||
@@ -243,6 +247,7 @@ __all__ = [
|
||||
"process_stream_chunks",
|
||||
"supports_structured_outputs",
|
||||
"supports_streaming_with_tools",
|
||||
"max_output_tokens",
|
||||
"ModelRouter",
|
||||
"ModelProfile",
|
||||
"TaskComplexity",
|
||||
|
||||
@@ -38,6 +38,17 @@ def _base_model_name(model_name: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def _coerce_output_token_limit(value) -> int | None:
|
||||
"""Return a positive integer output ceiling, or ``None`` when invalid."""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return coerced if coerced > 0 else None
|
||||
|
||||
|
||||
def _fallback_supports_structured_outputs(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
@@ -577,8 +588,8 @@ def max_output_tokens(model_name: str):
|
||||
try:
|
||||
info = litellm.get_model_info(model=model_name)
|
||||
if info:
|
||||
out = info.get("max_output_tokens")
|
||||
if out:
|
||||
out = _coerce_output_token_limit(info.get("max_output_tokens"))
|
||||
if out is not None:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
@@ -594,7 +605,7 @@ def max_output_tokens(model_name: str):
|
||||
if info is None and "/" in model_name:
|
||||
info = model_cost.get(model_name.split("/")[-1].lower())
|
||||
if info:
|
||||
return info.get("max_output_tokens")
|
||||
return _coerce_output_token_limit(info.get("max_output_tokens"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -617,4 +628,4 @@ def is_gemini_internal_tool(tool) -> bool:
|
||||
if isinstance(tool, dict) and len(tool) == 1:
|
||||
tool_name = next(iter(tool.keys()))
|
||||
return tool_name in GEMINI_INTERNAL_TOOLS
|
||||
return False
|
||||
return False
|
||||
@@ -40,6 +40,7 @@ def _clear_caches():
|
||||
mc.supports_parallel_function_calling,
|
||||
mc.supports_web_search,
|
||||
mc.supports_prompt_caching,
|
||||
mc.max_output_tokens,
|
||||
):
|
||||
fn.cache_clear()
|
||||
|
||||
@@ -204,3 +205,13 @@ def test_max_output_tokens_unknown_model_is_none():
|
||||
with _litellm_with(get_info=_raise, model_cost={}):
|
||||
assert mc.max_output_tokens("totally-unknown-model") is None
|
||||
mc.max_output_tokens.cache_clear()
|
||||
|
||||
|
||||
def test_max_output_tokens_unknown_or_invalid_is_none():
|
||||
mc.max_output_tokens.cache_clear()
|
||||
fake = SimpleNamespace(
|
||||
get_model_info=lambda *, model: {"max_output_tokens": "not-a-number"}
|
||||
)
|
||||
with patch.object(mc, "_get_litellm", return_value=fake):
|
||||
assert mc.max_output_tokens("unknown-model") is None
|
||||
mc.max_output_tokens.cache_clear()
|
||||
@@ -20,65 +20,81 @@ app = typer.Typer(help="Run agents")
|
||||
_FRAMEWORK_HELP = "Framework: praisonai, crewai, autogen"
|
||||
|
||||
_ALLOW_LOCAL_TOOLS_ENV = "PRAISONAI_ALLOW_LOCAL_TOOLS"
|
||||
|
||||
# Model-independent fallback used only when the resolved model's real output
|
||||
# ceiling is unknown (litellm absent or model not in its registry). Keeping this
|
||||
# equal to the historical CLI default makes the derivation backward-compatible.
|
||||
_PREFERRED_DEFAULT_MAX_TOKENS = 16000
|
||||
DEFAULT_MAX_TOKENS = 16000
|
||||
|
||||
|
||||
def _resolve_max_tokens(
|
||||
model_name: Optional[str],
|
||||
max_tokens: int,
|
||||
was_explicit: bool,
|
||||
output,
|
||||
model: Optional[str],
|
||||
requested: Optional[int],
|
||||
*,
|
||||
output: Any = None,
|
||||
) -> int:
|
||||
"""Derive/clamp ``--max-tokens`` against the resolved model's output ceiling.
|
||||
"""Resolve the output budget against the selected model's known limit.
|
||||
|
||||
- Unset by the user/config: default to ``min(preferred, ceiling)`` so a
|
||||
large-output model is not silently capped at the constant and a small one
|
||||
is not sent an over-limit request.
|
||||
- Explicitly set above the ceiling: clamp down with a one-line diagnostic so
|
||||
the provider does not 400.
|
||||
- Ceiling unknown (no litellm / unknown model): return the value unchanged,
|
||||
preserving today's behaviour.
|
||||
LiteLLM metadata is optional and best-effort. Unknown models retain the
|
||||
historical default, while an omitted budget uses the known model limit and
|
||||
an explicit oversized value is capped with a diagnostic.
|
||||
"""
|
||||
if not model_name:
|
||||
return max_tokens
|
||||
explicit = requested is not None
|
||||
value = DEFAULT_MAX_TOKENS if requested is None else requested
|
||||
if not model or value <= 0:
|
||||
return value
|
||||
|
||||
try:
|
||||
from praisonaiagents.llm.model_capabilities import max_output_tokens
|
||||
ceiling = max_output_tokens(model_name)
|
||||
|
||||
ceiling = max_output_tokens(model)
|
||||
except Exception:
|
||||
ceiling = None
|
||||
if not ceiling:
|
||||
return max_tokens
|
||||
if was_explicit:
|
||||
if max_tokens > ceiling:
|
||||
output.print_warning(
|
||||
f"--max-tokens {max_tokens} exceeds {model_name} limit; "
|
||||
f"clamping to {ceiling}"
|
||||
|
||||
if not ceiling or ceiling <= 0:
|
||||
return value
|
||||
if not explicit:
|
||||
# A known limit is authoritative. Returning the full ceiling avoids
|
||||
# retaining the historical 16k cap on models that can emit more.
|
||||
return ceiling
|
||||
if value <= ceiling:
|
||||
return value
|
||||
|
||||
message = (
|
||||
f"--max-tokens {value} exceeds {model}'s output limit ({ceiling}); "
|
||||
f"clamping to {ceiling}"
|
||||
)
|
||||
if output is not None:
|
||||
output_mode = getattr(getattr(output, "mode", None), "value", None)
|
||||
if output_mode == "stream-json" and hasattr(output, "emit_event"):
|
||||
# stream-json is an NDJSON protocol: never use print_json(), whose
|
||||
# indentation emits a multi-line object and corrupts framing.
|
||||
output.emit_event(
|
||||
"warning",
|
||||
message=message,
|
||||
data={"code": "max_tokens_clamped"},
|
||||
)
|
||||
return ceiling
|
||||
return max_tokens
|
||||
return min(_PREFERRED_DEFAULT_MAX_TOKENS, ceiling)
|
||||
elif getattr(output, "is_json_mode", False) and hasattr(output, "print_json"):
|
||||
# ``print_warning`` intentionally suppresses human text in JSON
|
||||
# mode. Emit a structured diagnostic so automation can see that
|
||||
# the requested value was lowered without corrupting stderr.
|
||||
output.print_json({"warning": message, "code": "max_tokens_clamped"})
|
||||
else:
|
||||
output.print_warning(message)
|
||||
return ceiling
|
||||
|
||||
|
||||
def _apply_max_tokens(agent_config: Dict[str, Any], max_tokens: Optional[int]) -> None:
|
||||
"""Fold a resolved output budget into an ``Agent(**agent_config)`` dict.
|
||||
|
||||
Mirrors the direct-prompt handler, which passes ``max_tokens`` inside the
|
||||
``llm`` config dict (``{"model": ..., "max_tokens": ...}``). Normalising a
|
||||
plain ``llm`` string into that dict here means the ceiling-derived/clamped
|
||||
budget reaches the agent on the ``--agent`` and ``actions`` paths too, so
|
||||
they no longer send an over-limit request the default prompt path avoids.
|
||||
"""
|
||||
if not max_tokens:
|
||||
return
|
||||
llm = agent_config.get("llm")
|
||||
if isinstance(llm, dict):
|
||||
llm.setdefault("max_tokens", max_tokens)
|
||||
elif isinstance(llm, str):
|
||||
agent_config["llm"] = {"model": llm, "max_tokens": max_tokens}
|
||||
def _llm_spec_with_max_tokens(
|
||||
llm_spec: Any,
|
||||
model: Optional[str],
|
||||
max_tokens: Optional[int],
|
||||
) -> Any:
|
||||
"""Add a resolved output budget to an LLM spec without losing options."""
|
||||
if max_tokens is None:
|
||||
return llm_spec
|
||||
spec = dict(llm_spec) if isinstance(llm_spec, dict) else {}
|
||||
if not spec.get("model"):
|
||||
spec["model"] = llm_spec if isinstance(llm_spec, str) else model
|
||||
if not spec.get("model"):
|
||||
return llm_spec
|
||||
spec["max_tokens"] = max_tokens
|
||||
return spec
|
||||
|
||||
|
||||
def _run_succeeded(result: Any) -> bool:
|
||||
@@ -1217,7 +1233,7 @@ def run_main(
|
||||
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool names (e.g. web_search,github) or a tools.py file path"),
|
||||
toolset: Optional[str] = typer.Option(None, "--toolset", help="Named toolset groups (comma-separated, e.g., web,files)"),
|
||||
allow_local_tools: bool = typer.Option(False, "--allow-local-tools", help="Load project-local .praisonai/tools/*.py (equivalent to PRAISONAI_ALLOW_LOCAL_TOOLS=true)"),
|
||||
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
|
||||
max_tokens: Optional[int] = typer.Option(None, "--max-tokens", help="Maximum output tokens (defaults to the known model limit; 16000 when unknown)"),
|
||||
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
|
||||
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
|
||||
output_mode: Optional[str] = typer.Option(None, "--output", "-o", help="Output mode: silent (default), actions, verbose, json, stream"),
|
||||
@@ -1430,24 +1446,64 @@ def run_main(
|
||||
# identically instead of dead-ending on a raw provider error. Headless
|
||||
# (non-TTY / --output json) fails fast with the actionable hint; interactive
|
||||
# offers the wizard and re-checks.
|
||||
if target: # Only check if we actually have something to run
|
||||
# YAML agents may select a different model (or an explicit per-agent
|
||||
# max_tokens), so leave their raw option untouched and let the YAML
|
||||
# generator resolve each agent against its own ceiling. Prompt/custom-agent
|
||||
# paths use the resolved top-level model here.
|
||||
is_yaml_target = bool(target and _is_yaml_file(target))
|
||||
# Loading a named custom definition performs a complete discovery pass
|
||||
# (including opt-in local tool modules). Keep the preview result so the
|
||||
# execution path does not discover and execute the same definitions twice.
|
||||
preloaded_agent_config = None
|
||||
custom_agent_model = None
|
||||
if target or agent or command: # Any execution surface may need model/budget resolution
|
||||
import sys
|
||||
from praisonai_code.llm.credentials import ensure_configured_or_onboard
|
||||
|
||||
_headless = (not sys.stdin.isatty()) or output.is_json_mode
|
||||
model = ensure_configured_or_onboard(model=model, interactive=not _headless)
|
||||
# A custom agent may declare its model in frontmatter. Resolve that
|
||||
# model before deriving the default output budget; otherwise an omitted
|
||||
# ``--model`` is budgeted at the generic 16k fallback and then attached
|
||||
# to the frontmatter model, which can have a different ceiling.
|
||||
if agent and model is None:
|
||||
try:
|
||||
from praisonai_code.cli.features.custom_definitions import (
|
||||
load_agent_from_name,
|
||||
)
|
||||
|
||||
# Derive/clamp the output budget from the now-resolved model's real ceiling
|
||||
# (Issue #5017). A bare `run` no longer 400s on lower-ceiling models nor
|
||||
# truncates silently on higher-ceiling ones; an explicit over-limit value is
|
||||
# clamped with a diagnostic. Unknown-metadata models keep today's constant.
|
||||
try:
|
||||
import click as _click
|
||||
_mt_src = _click.get_current_context().get_parameter_source("max_tokens")
|
||||
_max_tokens_explicit = _mt_src is not None and _mt_src.name != "DEFAULT"
|
||||
except Exception:
|
||||
_max_tokens_explicit = False
|
||||
max_tokens = _resolve_max_tokens(model, max_tokens, _max_tokens_explicit, output)
|
||||
_preview_config = load_agent_from_name(agent)
|
||||
preloaded_agent_config = _preview_config
|
||||
_preview_llm = (_preview_config or {}).get("llm")
|
||||
if isinstance(_preview_llm, dict):
|
||||
custom_agent_model = _preview_llm.get("model")
|
||||
else:
|
||||
custom_agent_model = _preview_llm
|
||||
if isinstance(custom_agent_model, str):
|
||||
custom_agent_model = custom_agent_model.strip() or None
|
||||
except Exception:
|
||||
# The normal custom-agent load below reports missing/invalid
|
||||
# definitions; preview is only for model-aware budgeting.
|
||||
pass
|
||||
|
||||
_headless = (not sys.stdin.isatty()) or output.is_json_mode
|
||||
# A frontmatter model is used for credential checks and budget
|
||||
# resolution, but must not be passed as a synthetic ``--model``
|
||||
# override: doing so replaces a mapping-valued LLM spec and discards
|
||||
# endpoint, credentials, sampling, and retry options.
|
||||
credential_model = model or custom_agent_model
|
||||
resolved_model = ensure_configured_or_onboard(
|
||||
model=credential_model, interactive=not _headless
|
||||
)
|
||||
if not (agent and model is None and custom_agent_model):
|
||||
model = resolved_model
|
||||
# A YAML file is a workflow target only when no named custom agent or
|
||||
# command is selected. In the latter cases the file is merely the
|
||||
# prompt/argument source and the named definition still needs its own
|
||||
# model-aware budget resolution.
|
||||
if not is_yaml_target or agent or command:
|
||||
budget_model = custom_agent_model or model
|
||||
max_tokens = _resolve_max_tokens(
|
||||
budget_model, max_tokens, output=output
|
||||
)
|
||||
|
||||
# Worktree isolation runs the agent in a chdir'd worktree in-process; the
|
||||
# warm runtime is a separate process whose cwd we can't redirect, so reject
|
||||
@@ -1513,7 +1569,11 @@ def run_main(
|
||||
# Handle custom agent or command
|
||||
if agent:
|
||||
from praisonai_code.cli.features.custom_definitions import load_agent_from_name
|
||||
agent_config = load_agent_from_name(agent)
|
||||
agent_config = (
|
||||
preloaded_agent_config
|
||||
if preloaded_agent_config is not None
|
||||
else load_agent_from_name(agent)
|
||||
)
|
||||
if not agent_config:
|
||||
output.print_error(f"Agent '{agent}' not found")
|
||||
raise typer.Exit(1)
|
||||
@@ -1596,7 +1656,6 @@ def run_main(
|
||||
instructions=merged_instructions,
|
||||
append_system_prompt=resolved_append_prompt,
|
||||
image=image,
|
||||
max_tokens_explicit=_max_tokens_explicit,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1686,6 +1745,7 @@ def run_main(
|
||||
approve_all_tools=approve_all_tools,
|
||||
approval_timeout=approval_timeout,
|
||||
output_mode=output_mode,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
else:
|
||||
# Profiling for direct prompt
|
||||
@@ -1703,6 +1763,7 @@ def run_main(
|
||||
approval=approval,
|
||||
approve_all_tools=approve_all_tools,
|
||||
approval_timeout=approval_timeout,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1783,7 +1844,6 @@ def run_main(
|
||||
instructions=merged_instructions,
|
||||
append_system_prompt=resolved_append_prompt,
|
||||
image=image,
|
||||
max_tokens_explicit=_max_tokens_explicit,
|
||||
)
|
||||
|
||||
|
||||
@@ -1797,7 +1857,7 @@ def _run_from_file(
|
||||
trace: bool = False,
|
||||
memory: bool = False,
|
||||
tools: Optional[str] = None,
|
||||
max_tokens: int = 16000,
|
||||
max_tokens: Optional[int] = None,
|
||||
output_mode: Optional[str] = None,
|
||||
continue_session: bool = False,
|
||||
session: Optional[str] = None,
|
||||
@@ -1825,14 +1885,6 @@ def _run_from_file(
|
||||
# Set model if provided
|
||||
if model:
|
||||
praison.config_list[0]['model'] = model
|
||||
# Carry the ceiling-derived/clamped output budget on the model config so
|
||||
# generators that honour ``max_tokens`` from ``config_list`` pick it up.
|
||||
# Note: a multi-model YAML file can pin a different ``llm:`` per agent;
|
||||
# the CLI ceiling was resolved once against the effective ``--model`` (or
|
||||
# the process default), so per-agent YAML models are budgeted by the core
|
||||
# context layer rather than re-clamped here.
|
||||
if max_tokens:
|
||||
praison.config_list[0]['max_tokens'] = max_tokens
|
||||
|
||||
# Handle session continuity for YAML files
|
||||
session_id = None
|
||||
@@ -1893,6 +1945,7 @@ def _run_from_file(
|
||||
or effective_approval
|
||||
or approve_all_tools
|
||||
or output_mode
|
||||
or max_tokens is not None
|
||||
):
|
||||
class Args:
|
||||
pass
|
||||
@@ -1902,6 +1955,8 @@ def _run_from_file(
|
||||
args.resume_session = session_id
|
||||
args.cli_project_sessions = bool(session_id or auto_save_name)
|
||||
args.output = output_mode
|
||||
args.max_tokens = max_tokens
|
||||
args._max_tokens_explicit = max_tokens is not None
|
||||
if effective_approval:
|
||||
args.approval = effective_approval
|
||||
if approve_all_tools:
|
||||
@@ -1967,7 +2022,6 @@ def _run_prompt(
|
||||
instructions: Optional[List[str]] = None,
|
||||
append_system_prompt: Optional[str] = None,
|
||||
image: Optional[List[str]] = None,
|
||||
max_tokens_explicit: bool = False,
|
||||
):
|
||||
"""Run a direct prompt."""
|
||||
output = get_output_controller()
|
||||
@@ -2057,19 +2111,17 @@ def _run_prompt(
|
||||
# PRAISONAI_APPEND_SYSTEM_PROMPT export, and it reuses a cached agent
|
||||
# whose system prompt is already assembled — so attaching would silently
|
||||
# drop the requested suffix. The in-process path applies it correctly.
|
||||
# An explicit/derived per-call --max-tokens must also stay in-process:
|
||||
# the warm runtime is a separate process reusing a cached agent whose
|
||||
# output budget was fixed at startup, so attaching would silently drop
|
||||
# the requested (possibly ceiling-clamped) budget — the same failure
|
||||
# mode as thinking_budget/append_system_prompt above.
|
||||
stateful_attach = bool(session_id) and not fork
|
||||
runtime_eligible = (
|
||||
(no_save or stateful_attach)
|
||||
and thinking_budget is None
|
||||
# The warm runtime API has no per-request output-budget field. A
|
||||
# resolved non-default budget must stay in-process so it cannot be
|
||||
# silently replaced by the runtime's own default.
|
||||
and max_tokens == DEFAULT_MAX_TOKENS
|
||||
and not isolated
|
||||
and not append_system_prompt
|
||||
and not image
|
||||
and not max_tokens_explicit
|
||||
and not any([
|
||||
mcp, mcp_servers, tools, toolset, approval, approve_all_tools,
|
||||
memory, permissions_config, fork, instructions,
|
||||
@@ -2109,9 +2161,9 @@ def _run_prompt(
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
# Apply the ceiling-derived/clamped output budget so the actions
|
||||
# fast path honours the same limit as the default prompt path.
|
||||
_apply_max_tokens(agent_config, max_tokens)
|
||||
agent_config["llm"] = _llm_spec_with_max_tokens(
|
||||
agent_config.get("llm"), model, max_tokens
|
||||
)
|
||||
|
||||
# Resolve approval backend if specified
|
||||
if approval:
|
||||
@@ -2224,6 +2276,7 @@ def _run_prompt(
|
||||
args.tools = tools
|
||||
args.toolset = toolset
|
||||
args.max_tokens = max_tokens
|
||||
args._max_tokens_explicit = max_tokens is not None
|
||||
args.web_search = False
|
||||
args.web_fetch = False
|
||||
args.prompt_caching = False
|
||||
@@ -2343,6 +2396,7 @@ def _run_from_file_profiled(
|
||||
approve_all_tools: bool = False,
|
||||
approval_timeout: Optional[str] = None,
|
||||
output_mode: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
):
|
||||
"""Run agents from a YAML file with profiling enabled."""
|
||||
from praisonai_code.cli.features.cli_profiler import (
|
||||
@@ -2410,7 +2464,7 @@ def _run_from_file_profiled(
|
||||
# the same ``args`` the legacy YAML path reads, so a profiled YAML run is
|
||||
# permission-gated identically to the non-profiled path instead of silently
|
||||
# dropping the deny policy.
|
||||
if session_id or auto_save_name or approval or approve_all_tools or output_mode:
|
||||
if session_id or auto_save_name or approval or approve_all_tools or output_mode or max_tokens is not None:
|
||||
class Args:
|
||||
pass
|
||||
|
||||
@@ -2419,6 +2473,8 @@ def _run_from_file_profiled(
|
||||
args.resume_session = session_id
|
||||
args.cli_project_sessions = bool(session_id or auto_save_name)
|
||||
args.output = output_mode
|
||||
args.max_tokens = max_tokens
|
||||
args._max_tokens_explicit = max_tokens is not None
|
||||
if approval:
|
||||
args.approval = approval
|
||||
if approve_all_tools:
|
||||
@@ -2627,14 +2683,20 @@ def _run_custom_agent(
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Override model if specified
|
||||
# Override only the model field when the definition supplies a full
|
||||
# LLM mapping. Replacing the mapping wholesale would discard endpoint,
|
||||
# credentials, sampling, retry, and provider-specific options.
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
|
||||
# Fold the ceiling-derived/clamped output budget into the agent's llm
|
||||
# config so a `--agent` run honours the same limit as the default
|
||||
# prompt path (only when the definition did not already pin one).
|
||||
_apply_max_tokens(agent_config, max_tokens)
|
||||
if isinstance(agent_config.get("llm"), dict):
|
||||
agent_config["llm"] = {
|
||||
**agent_config["llm"],
|
||||
"model": model,
|
||||
}
|
||||
else:
|
||||
agent_config["llm"] = model
|
||||
agent_config["llm"] = _llm_spec_with_max_tokens(
|
||||
agent_config.get("llm"), model, max_tokens
|
||||
)
|
||||
|
||||
# Compose the agent's toolset: frontmatter ``tools:`` (name strings)
|
||||
# + explicit --tools/--toolset + auto-discovered project-local
|
||||
@@ -2830,6 +2892,7 @@ def _run_prompt_profiled(
|
||||
approval: Optional[str] = None,
|
||||
approve_all_tools: bool = False,
|
||||
approval_timeout: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
):
|
||||
"""Run a direct prompt with profiling enabled."""
|
||||
from praisonai_code.cli.features.cli_profiler import (
|
||||
@@ -2866,6 +2929,9 @@ def _run_prompt_profiled(
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
agent_config["llm"] = _llm_spec_with_max_tokens(
|
||||
agent_config.get("llm"), model, max_tokens
|
||||
)
|
||||
|
||||
# Thread the approval backend (e.g. --plan -> PermissionMode.PLAN) into the
|
||||
# profiled agent so a read-only planning run stays read-only under
|
||||
|
||||
@@ -2053,6 +2053,16 @@ class PraisonAI:
|
||||
if output_mode is not None:
|
||||
cli_config['output'] = output_mode
|
||||
|
||||
max_tokens = getattr(self.args, 'max_tokens', None)
|
||||
if max_tokens is not None:
|
||||
cli_config['max_tokens'] = max_tokens
|
||||
# Newer callers mark whether the CLI option was explicitly supplied;
|
||||
# retain that bit so YAML agent-level budgets are not shadowed by a
|
||||
# parser default of 16000.
|
||||
explicit_max_tokens = getattr(self.args, '_max_tokens_explicit', None)
|
||||
if explicit_max_tokens is not None:
|
||||
cli_config['_max_tokens_explicit'] = bool(explicit_max_tokens)
|
||||
|
||||
# Extract handoff configuration for YAML CLI parity
|
||||
handoff = getattr(self.args, 'handoff', None)
|
||||
if handoff:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for model-aware ``run --max-tokens`` resolution."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from praisonai_code.cli.commands import run as run_command
|
||||
|
||||
|
||||
def test_default_budget_is_capped_to_known_model_limit(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 8192,
|
||||
)
|
||||
|
||||
assert run_command._resolve_max_tokens("provider/model", None) == 8192
|
||||
|
||||
|
||||
def test_default_budget_uses_large_known_model_limit(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 32768,
|
||||
)
|
||||
|
||||
assert run_command._resolve_max_tokens("provider/model", None) == 32768
|
||||
|
||||
|
||||
def test_explicit_budget_is_capped_with_warning(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 8192,
|
||||
)
|
||||
warnings = []
|
||||
output = SimpleNamespace(print_warning=warnings.append)
|
||||
|
||||
assert run_command._resolve_max_tokens(
|
||||
"provider/model", 12000, output=output
|
||||
) == 8192
|
||||
assert warnings == [
|
||||
"--max-tokens 12000 exceeds provider/model's output limit (8192); "
|
||||
"clamping to 8192"
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_budget_warning_is_single_ndjson_event_in_stream_mode(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 8192,
|
||||
)
|
||||
events = []
|
||||
output = SimpleNamespace(
|
||||
mode=SimpleNamespace(value="stream-json"),
|
||||
emit_event=lambda *args, **kwargs: events.append((args, kwargs)),
|
||||
is_json_mode=True,
|
||||
)
|
||||
|
||||
assert run_command._resolve_max_tokens(
|
||||
"provider/model", 12000, output=output
|
||||
) == 8192
|
||||
assert events == [
|
||||
(
|
||||
("warning",),
|
||||
{
|
||||
"message": "--max-tokens 12000 exceeds provider/model's output limit (8192); clamping to 8192",
|
||||
"data": {"code": "max_tokens_clamped"},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_model_preserves_historical_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: None,
|
||||
)
|
||||
|
||||
assert run_command._resolve_max_tokens("unknown/model", None) == 16000
|
||||
|
||||
|
||||
def test_llm_spec_budget_preserves_existing_options():
|
||||
spec = {"model": "provider/model", "temperature": 0.2}
|
||||
|
||||
resolved = run_command._llm_spec_with_max_tokens(spec, None, 4096)
|
||||
|
||||
assert resolved == {
|
||||
"model": "provider/model",
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
assert spec == {"model": "provider/model", "temperature": 0.2}
|
||||
|
||||
|
||||
def test_yaml_budget_resolution_preserves_nested_llm_value(monkeypatch):
|
||||
from praisonai.framework_adapters.praisonai_adapter import (
|
||||
_build_agent_llm_spec,
|
||||
_requested_output_budget,
|
||||
_resolve_output_budget,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 8192,
|
||||
)
|
||||
details = {
|
||||
"llm": {
|
||||
"model": "provider/model",
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
}
|
||||
|
||||
# A legacy parser default must not shadow an explicit nested YAML value.
|
||||
assert _requested_output_budget(details, {"max_tokens": 16000}) == 2048
|
||||
assert _resolve_output_budget("provider/model", 2048) == 2048
|
||||
spec = _build_agent_llm_spec(details["llm"], "provider/model", 2048)
|
||||
assert spec == {
|
||||
"model": "provider/model",
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
|
||||
|
||||
def test_yaml_explicit_cli_budget_wins_over_nested_value():
|
||||
from praisonai.framework_adapters.praisonai_adapter import (
|
||||
_requested_output_budget,
|
||||
)
|
||||
|
||||
details = {"llm": {"model": "provider/model", "max_tokens": 2048}}
|
||||
assert _requested_output_budget(
|
||||
details,
|
||||
{"max_tokens": 4096, "_max_tokens_explicit": True},
|
||||
) == 4096
|
||||
@@ -729,7 +729,7 @@ class AgentsGenerator:
|
||||
break
|
||||
|
||||
# Handle agent-level overrides using unified approach
|
||||
agent_level_fields = ['tool_timeout', 'tool_retry_policy', 'planning_tools', 'autonomy', 'planning', 'web', 'web_fetch']
|
||||
agent_level_fields = ['tool_timeout', 'tool_retry_policy', 'planning_tools', 'autonomy', 'planning', 'web', 'web_fetch', 'max_tokens']
|
||||
agent_overrides = {k: v for k, v in cli_config.items() if k in agent_level_fields}
|
||||
|
||||
if "tool_retry_policy" in agent_overrides:
|
||||
|
||||
@@ -16,6 +16,118 @@ from .base import BaseFrameworkAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_MAX_TOKENS = 16000
|
||||
|
||||
|
||||
def _active_correlation_id() -> Optional[str]:
|
||||
"""Return the current workflow id for budget diagnostics when available."""
|
||||
try:
|
||||
from praisonaiagents.trace.context_events import get_context_emitter
|
||||
|
||||
session_id = getattr(get_context_emitter(), "_session_id", None)
|
||||
if session_id:
|
||||
return str(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.session.context import get_session_context
|
||||
|
||||
context = get_session_context()
|
||||
for field in ("thread_id", "chat_id", "user_id"):
|
||||
value = getattr(context, field, None)
|
||||
if value:
|
||||
return str(value)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_output_budget(model: str, requested: Any = None) -> Optional[int]:
|
||||
"""Resolve a YAML agent budget against LiteLLM's model ceiling."""
|
||||
try:
|
||||
value = _DEFAULT_MAX_TOKENS if requested is None else int(requested)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
correlation_id = _active_correlation_id()
|
||||
logger.warning(
|
||||
"Ignoring invalid max_tokens=%r for model %r",
|
||||
requested,
|
||||
model,
|
||||
extra={"extra_data": {"correlation_id": correlation_id}}
|
||||
if correlation_id
|
||||
else {},
|
||||
)
|
||||
value = _DEFAULT_MAX_TOKENS
|
||||
if not model or value <= 0:
|
||||
return value
|
||||
try:
|
||||
from praisonaiagents.llm.model_capabilities import max_output_tokens
|
||||
|
||||
ceiling = max_output_tokens(model)
|
||||
except Exception:
|
||||
ceiling = None
|
||||
if not ceiling or ceiling <= 0:
|
||||
return value
|
||||
if value > ceiling:
|
||||
if requested is not None:
|
||||
correlation_id = _active_correlation_id()
|
||||
logger.warning(
|
||||
"max_tokens=%s exceeds %s's output limit (%s); clamping to %s",
|
||||
value, model, ceiling, ceiling,
|
||||
extra={"extra_data": {"correlation_id": correlation_id}}
|
||||
if correlation_id
|
||||
else {},
|
||||
)
|
||||
return ceiling
|
||||
# An omitted YAML budget follows the model's full known ceiling, including
|
||||
# ceilings above the historical 16k default.
|
||||
return ceiling if requested is None else value
|
||||
|
||||
|
||||
def _build_agent_llm_spec(
|
||||
raw_llm_spec: Any,
|
||||
model: str,
|
||||
max_tokens: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
"""Overlay resolved model/budget fields without dropping YAML options."""
|
||||
spec = dict(raw_llm_spec) if isinstance(raw_llm_spec, dict) else {}
|
||||
spec["model"] = model
|
||||
spec["max_tokens"] = max_tokens
|
||||
return spec
|
||||
|
||||
|
||||
def _requested_output_budget(
|
||||
details: Dict[str, Any], cli_config: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""Select the raw per-agent budget before model-ceiling resolution.
|
||||
|
||||
An explicit CLI value wins over YAML. When the CLI option is omitted,
|
||||
preserve the agent-level value and then a nested ``llm.max_tokens`` value;
|
||||
the latter must not be mistaken for an omitted budget and overwritten by
|
||||
the model ceiling. Older callers may pass the parser's historical 16k
|
||||
default without an explicitness marker, so that value is treated as an
|
||||
omission when a YAML value is available.
|
||||
"""
|
||||
details = details or {}
|
||||
cli = cli_config or {}
|
||||
marker = cli.get("_max_tokens_explicit")
|
||||
cli_value = cli.get("max_tokens")
|
||||
if marker is None:
|
||||
marker = (
|
||||
"max_tokens" in cli
|
||||
and cli_value is not None
|
||||
and cli_value != _DEFAULT_MAX_TOKENS
|
||||
)
|
||||
if marker:
|
||||
return cli_value
|
||||
|
||||
agent_value = details.get("max_tokens")
|
||||
if agent_value is not None:
|
||||
return agent_value
|
||||
llm_spec = details.get("llm")
|
||||
if isinstance(llm_spec, dict):
|
||||
return llm_spec.get("max_tokens")
|
||||
return None
|
||||
|
||||
|
||||
class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
"""
|
||||
@@ -345,7 +457,17 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
return llm_config[0]['model']
|
||||
return "gpt-4o-mini"
|
||||
|
||||
def _build_agents_and_tasks(self, config, topic, tools_dict, agent_callback, task_callback, model_name, agent_tool_wrap_resolver=None):
|
||||
def _build_agents_and_tasks(
|
||||
self,
|
||||
config,
|
||||
topic,
|
||||
tools_dict,
|
||||
agent_callback,
|
||||
task_callback,
|
||||
model_name,
|
||||
agent_tool_wrap_resolver=None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Build agents and tasks from configuration."""
|
||||
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask
|
||||
from ._config_builder import build_agent_specs
|
||||
@@ -378,6 +500,15 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
|
||||
# Resolve per-agent LLM model
|
||||
agent_model = self._resolve_agent_model(details, model_name)
|
||||
|
||||
# CLI max_tokens is an explicit global override when present;
|
||||
# otherwise honour each YAML agent's own value (including a nested
|
||||
# ``llm.max_tokens``) and resolve an omitted value from that
|
||||
# agent's model ceiling.
|
||||
requested_max_tokens = _requested_output_budget(details, cli_config)
|
||||
resolved_max_tokens = _resolve_output_budget(
|
||||
agent_model, requested_max_tokens
|
||||
)
|
||||
|
||||
# Resolve per-agent runtime configuration
|
||||
agent_runtime = self._resolve_agent_runtime(details, config)
|
||||
@@ -385,6 +516,14 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
# Resolve approval configuration
|
||||
agent_approval = self._resolve_agent_approval(details, config)
|
||||
|
||||
# Preserve the complete YAML LLM mapping (endpoint, credentials,
|
||||
# sampling, retries, and provider-specific options) while adding
|
||||
# the resolved model/budget fields. Rebuilding from only ``model``
|
||||
# silently discarded those settings.
|
||||
agent_llm = _build_agent_llm_spec(
|
||||
details.get('llm'), agent_model, resolved_max_tokens
|
||||
)
|
||||
|
||||
# Create basic agent (pass both tools and toolsets)
|
||||
agent_kwargs = {
|
||||
'name': role_filled,
|
||||
@@ -392,7 +531,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
'goal': goal_filled,
|
||||
'backstory': backstory_filled,
|
||||
'instructions': details.get('instructions'),
|
||||
'llm': agent_model,
|
||||
'llm': agent_llm,
|
||||
'allow_delegation': details.get('allow_delegation', False),
|
||||
'tools': agent_tool_list,
|
||||
'toolsets': agent_toolsets,
|
||||
@@ -780,6 +919,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
agents, tasks = self._build_agents_and_tasks(
|
||||
config, topic, tools_dict, agent_callback, task_callback, model_name,
|
||||
agent_tool_wrap_resolver=agent_tool_wrap_resolver,
|
||||
cli_config=cli_config,
|
||||
)
|
||||
|
||||
# Resolve CLI session continuity (--continue/--session/--fork) that the
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Regression tests for YAML model-aware output budgets."""
|
||||
|
||||
from praisonai.framework_adapters.praisonai_adapter import (
|
||||
_build_agent_llm_spec,
|
||||
_resolve_output_budget,
|
||||
)
|
||||
|
||||
|
||||
def test_yaml_budget_uses_output_ceiling_for_omitted_value(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.model_capabilities.max_output_tokens",
|
||||
lambda model: 8192,
|
||||
)
|
||||
|
||||
assert _resolve_output_budget("provider/model") == 8192
|
||||
assert _resolve_output_budget("provider/model", 12000) == 8192
|
||||
|
||||
|
||||
def test_yaml_llm_spec_preserves_provider_options():
|
||||
raw = {
|
||||
"model": "provider/model",
|
||||
"temperature": 0.2,
|
||||
"api_base": "http://localhost:11434",
|
||||
"retry": {"max_attempts": 2},
|
||||
}
|
||||
|
||||
resolved = _build_agent_llm_spec(raw, "provider/model", 4096)
|
||||
|
||||
assert resolved == {
|
||||
**raw,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
assert resolved is not raw
|
||||
Reference in new issue
Block a user