Skip to content

Commit 337959a

Browse files
committed
feat(sampling): forward seed/top_k/penalties through Agent, reject unknown run() kwargs
1 parent 7d1a3bd commit 337959a

19 files changed

Lines changed: 424 additions & 9 deletions

effgen/cli/_main.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,7 @@ def run_agent(self, args):
777777
system_prompt=args.system_prompt or config.get("system_prompt"),
778778
max_iterations=args.max_iterations,
779779
temperature=args.temperature,
780+
max_tokens=args.max_tokens,
780781
enable_streaming=args.stream,
781782
session_id=getattr(args, 'session_id', None),
782783
**_preset_overrides,
@@ -823,6 +824,7 @@ def run_agent(self, args):
823824
"You are a helpful AI assistant."),
824825
temperature=args.temperature or config.get("temperature", 0.7),
825826
max_iterations=args.max_iterations or config.get("max_iterations", 10),
827+
max_tokens=args.max_tokens or config.get("max_tokens"),
826828
enable_sub_agents=not args.no_sub_agents,
827829
enable_streaming=args.stream
828830
)
@@ -2572,8 +2574,17 @@ def create_parser():
25722574
run_parser.add_argument('-n', '--name', help='Agent name')
25732575
run_parser.add_argument('-t', '--tools', nargs='+', help='Tools to enable')
25742576
run_parser.add_argument('-c', '--config', help='Configuration file')
2575-
run_parser.add_argument('--system-prompt', help='System prompt')
2577+
run_parser.add_argument(
2578+
'--system-prompt', '--persona', dest='system_prompt', metavar='TEXT',
2579+
help='Custom persona / system prompt for this run, e.g. '
2580+
'"You are a patient Socratic tutor who never gives the answer."',
2581+
)
25762582
run_parser.add_argument('--temperature', type=float, help='Temperature')
2583+
run_parser.add_argument('--max-tokens', type=int,
2584+
help='Max output tokens (raise for token-heavy or '
2585+
'reasoning models, e.g. gpt-5/o-series, which '
2586+
'spend part of the budget on hidden reasoning '
2587+
'before any visible text)')
25772588
run_parser.add_argument('--max-iterations', type=int, help='Max iterations')
25782589
run_parser.add_argument('--mode', choices=['auto', 'single', 'sub_agents'], help='Execution mode')
25792590
run_parser.add_argument('--no-sub-agents', action='store_true', help='Disable sub-agents')
@@ -2641,6 +2652,11 @@ def create_parser():
26412652
'Steers every reply (unlike --preset, which only labels the session).',
26422653
)
26432654
chat_parser.add_argument('--temperature', type=float, help='Temperature')
2655+
chat_parser.add_argument('--max-tokens', type=int,
2656+
help='Max output tokens per reply (raise for token-heavy '
2657+
'or reasoning models, e.g. gpt-5/o-series, which '
2658+
'spend part of the budget on hidden reasoning '
2659+
'before any visible text)')
26442660
chat_parser.add_argument('--no-sub-agents', action='store_true', help='Disable sub-agents')
26452661
chat_parser.add_argument('-v', '--verbose', action='store_true', default=argparse.SUPPRESS,
26462662
help='Verbose output (show DEBUG/INFO logs)')

effgen/cli/chat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(self, cli: Any, args: Any):
7575
# and `effgen sessions`), so a customer's conversation can be continued.
7676
self.session_id = getattr(args, "session_id", None)
7777
self.temperature = getattr(args, "temperature", None)
78+
self.max_tokens = getattr(args, "max_tokens", None)
7879
# Provider was validated by the caller; keep the canonical name.
7980
self.provider = getattr(args, "_provider", None) or getattr(args, "provider", None)
8081

@@ -126,6 +127,7 @@ def _build_agent(self, carry_from: Any = None) -> Any:
126127
"provider": self.provider,
127128
"tools": tools,
128129
"temperature": self.temperature if self.temperature is not None else 0.7,
130+
"max_tokens": self.max_tokens,
129131
"enable_sub_agents": not getattr(self.args, "no_sub_agents", False),
130132
"enable_streaming": True,
131133
}

effgen/core/agent.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import asyncio
16+
import difflib
1617
import functools
1718
import logging
1819
import time
@@ -139,6 +140,14 @@ class AgentConfig:
139140
system_prompt: System-level instructions
140141
max_iterations: Maximum tool-use loop iterations
141142
temperature: Generation temperature
143+
top_p: Nucleus-sampling threshold; overridden per call by run(top_p=...)
144+
top_k: Top-k sampling cutoff (providers that don't support it ignore it)
145+
seed: Sampling seed; a fixed seed plus temperature=0 reproduces a
146+
generation exactly, on providers that support it
147+
presence_penalty: Penalizes tokens already present anywhere in the text
148+
frequency_penalty: Penalizes tokens proportionally to how often they
149+
already appeared (the standard anti-repetition knob for long text)
150+
repetition_penalty: Multiplicative repeat penalty used by local/HF engines
142151
enable_sub_agents: Enable sub-agent spawning
143152
enable_memory: Enable memory systems
144153
enable_streaming: Enable response streaming
@@ -168,6 +177,16 @@ class AgentConfig:
168177
# Default output-token budget for every run(). None lets the model pick a
169178
# size-aware default; run(max_tokens=...) overrides it for a single call.
170179
max_tokens: int | None = None
180+
# Sampling controls. Pinned here they apply to every run(); a run(...)
181+
# kwarg of the same name overrides them for a single call. seed and the
182+
# penalties default to GenerationConfig's neutral values (no effect on
183+
# generation) so existing agents are unaffected until a caller sets one.
184+
top_p: float = 0.9
185+
top_k: int = 50
186+
seed: int | None = None
187+
presence_penalty: float = 0.0
188+
frequency_penalty: float = 0.0
189+
repetition_penalty: float = 1.0
171190
enable_sub_agents: bool = True
172191
enable_memory: bool = True
173192
enable_streaming: bool = False
@@ -227,6 +246,18 @@ def __post_init__(self) -> None:
227246
"apply_chat_template", "quantization", "trust_remote_code",
228247
})
229248

249+
# run()'s recognized **kwargs — generation controls plus the checkpoint/debug
250+
# knobs threaded through the tool loop. A name outside this set (and not
251+
# starting with "_", reserved for internal call-chain bookkeeping such as
252+
# resume()'s _resume_scratchpad) is almost always a typo, so run() rejects it
253+
# instead of silently ignoring it.
254+
_RUN_KWARGS = frozenset({
255+
"debug", "max_tokens", "temperature", "top_p", "top_k", "seed",
256+
"presence_penalty", "frequency_penalty", "repetition_penalty",
257+
"stop_sequences", "reasoning_effort", "tools",
258+
"checkpoint_dir", "checkpoint_interval", "max_iterations",
259+
})
260+
230261

231262
def _agentconfig_init_guard(_dataclass_init):
232263
"""Wrap AgentConfig.__init__ to translate a stray model-loading kwarg into a
@@ -934,6 +965,16 @@ def run(self,
934965
Returns:
935966
AgentResponse with results
936967
"""
968+
unrecognized = {k for k in kwargs if k not in _RUN_KWARGS and not k.startswith("_")}
969+
if unrecognized:
970+
bad = sorted(unrecognized)[0]
971+
close = difflib.get_close_matches(bad, sorted(_RUN_KWARGS), n=1, cutoff=0.5)
972+
hint = f" Did you mean '{close[0]}'?" if close else ""
973+
raise TypeError(
974+
f"run() got an unexpected keyword argument '{bad}'.{hint} "
975+
f"Recognized run() kwargs: {sorted(_RUN_KWARGS)}."
976+
)
977+
937978
start_time = time.time()
938979
context = context or {}
939980

@@ -1030,6 +1071,8 @@ def run(self,
10301071
if output_model is None and is_pydantic_model_class(raw_schema):
10311072
output_model = raw_schema
10321073

1074+
self._warn_reasoning_budget(kwargs.get("max_tokens"), effective_schema is not None)
1075+
10331076
# Track task start
10341077
self.execution_tracker.track_event(ExecutionEvent(
10351078
type=EventType.TASK_START,

effgen/core/agent_generation.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import time
1414
from typing import TYPE_CHECKING, Any
1515

16-
from ..models._adapter_utils import FINISH_LENGTH, default_max_output_tokens
16+
from ..models._adapter_utils import (
17+
FINISH_LENGTH,
18+
default_max_output_tokens,
19+
needs_reasoning_headroom,
20+
)
1721
from ..models.base import BaseModel, GenerationConfig
1822
from ..models.errors import (
1923
InvalidRequestError,
@@ -35,11 +39,20 @@
3539
_TRUNCATION_ESCALATION_FACTOR = 4
3640
_TRUNCATION_MAX_TOKENS_CEILING = 8192
3741

42+
# A reasoning model asked for structured output needs headroom well beyond the
43+
# reasoning-family default (it spends budget on hidden reasoning *and* the
44+
# JSON/schema structure before any field value). Below this, warn at call time
45+
# instead of only after a billed, truncated failure.
46+
_REASONING_STRUCTURED_OUTPUT_MIN_TOKENS = 8192
47+
3848
if TYPE_CHECKING:
3949
pass
4050

4151
logger = logging.getLogger(__name__)
4252
_slog = get_structured_logger(__name__)
53+
# (model_name, kind) pairs already warned about in this process — a call-time
54+
# reasoning-budget heads-up fires once per model/kind, not on every call.
55+
_reasoning_budget_warned: set[tuple[str, str]] = set()
4356
# Canonical structured observability logger — emits redacted JSON lines with OTel context
4457
_obs_log = _get_obs_logger(__name__)
4558

@@ -307,7 +320,12 @@ def _generate(self, prompt: Any, **kwargs) -> dict[str, Any]:
307320
gen_config = GenerationConfig(
308321
temperature=retry_temperature,
309322
max_tokens=current_max_tokens,
310-
top_p=kwargs.get('top_p', 0.9),
323+
top_p=kwargs.get('top_p', self.config.top_p),
324+
top_k=kwargs.get('top_k', self.config.top_k),
325+
seed=kwargs.get('seed', self.config.seed),
326+
presence_penalty=kwargs.get('presence_penalty', self.config.presence_penalty),
327+
frequency_penalty=kwargs.get('frequency_penalty', self.config.frequency_penalty),
328+
repetition_penalty=kwargs.get('repetition_penalty', self.config.repetition_penalty),
311329
stop_sequences=kwargs.get('stop_sequences', default_stop_sequences)
312330
)
313331

@@ -483,6 +501,47 @@ def _record_provider_metrics(
483501
output_tokens=int(completion_tokens or 0),
484502
)
485503

504+
def _warn_reasoning_budget(self, max_tokens: int | None, structured_output: bool) -> None:
505+
"""Warn once per model when a reasoning model's budget looks too tight.
506+
507+
gpt-5 / o-series models spend part of ``max_tokens`` on hidden
508+
reasoning before any visible token, so a small pinned budget — or a
509+
generous one paired with structured output, which needs headroom for
510+
the reasoning *and* the JSON/schema structure — can produce nothing
511+
and still be billed. Logged before the call so the caller sees it
512+
without first burning a failed, billed round-trip.
513+
"""
514+
model = self.model
515+
if model is None or not needs_reasoning_headroom(model):
516+
return
517+
name = getattr(model, "model_name", None) or self.model_name or "unknown"
518+
519+
if structured_output:
520+
min_tokens = _REASONING_STRUCTURED_OUTPUT_MIN_TOKENS
521+
if max_tokens is not None and max_tokens >= min_tokens:
522+
return
523+
kind, hint = "structured", (
524+
f"'{name}' is a reasoning model asked for structured output with "
525+
f"max_tokens={max_tokens if max_tokens is not None else 'default'} — "
526+
f"reasoning plus the JSON structure can consume the whole budget "
527+
f"before any field is filled. Consider max_tokens>={min_tokens}."
528+
)
529+
else:
530+
min_tokens = default_max_output_tokens(model)
531+
if max_tokens is None or max_tokens >= min_tokens:
532+
return
533+
kind, hint = "tight_budget", (
534+
f"'{name}' is a reasoning model with max_tokens={max_tokens} — it can "
535+
f"spend the whole budget on hidden reasoning and return no visible "
536+
f"text. Consider max_tokens>={min_tokens}."
537+
)
538+
539+
warn_key = (name, kind)
540+
if warn_key in _reasoning_budget_warned:
541+
return
542+
_reasoning_budget_warned.add(warn_key)
543+
logger.warning(hint)
544+
486545
def _truncation_error_detail(self, model: Any, max_tokens: int) -> dict[str, Any]:
487546
"""Structured error for a deterministic ``max_tokens`` truncation.
488547
@@ -705,7 +764,12 @@ def _generate_speculative(self, prompt: str, **kwargs) -> dict[str, Any] | None:
705764
gen_config = GenerationConfig(
706765
temperature=base_temperature,
707766
max_tokens=kwargs.get('max_tokens', default_max_output_tokens(self.model)),
708-
top_p=kwargs.get('top_p', 0.9),
767+
top_p=kwargs.get('top_p', self.config.top_p),
768+
top_k=kwargs.get('top_k', self.config.top_k),
769+
seed=kwargs.get('seed', self.config.seed),
770+
presence_penalty=kwargs.get('presence_penalty', self.config.presence_penalty),
771+
frequency_penalty=kwargs.get('frequency_penalty', self.config.frequency_penalty),
772+
repetition_penalty=kwargs.get('repetition_penalty', self.config.repetition_penalty),
709773
stop_sequences=kwargs.get('stop_sequences', default_stop_sequences),
710774
)
711775

effgen/models/_cost.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import json
2222
import logging
23+
import os
2324
import threading
2425
import warnings
2526
from dataclasses import dataclass
@@ -31,11 +32,20 @@
3132
_BUDGET_CONFIG_PATH = Path.home() / ".effgen" / "budget.json"
3233

3334

35+
def _budget_config_path() -> Path:
36+
"""Resolve the budget-config file, honoring an ``EFFGEN_BUDGET_CONFIG``
37+
override (mirrors ``EFFGEN_COST_DB``) so a sandbox or test run can point the
38+
budget away from the user's real ``~/.effgen/budget.json``."""
39+
env_path = os.environ.get("EFFGEN_BUDGET_CONFIG")
40+
return Path(env_path) if env_path else _BUDGET_CONFIG_PATH
41+
42+
3443
def _load_budget() -> dict:
35-
"""Load budget config from ~/.effgen/budget.json (returns empty dict if absent)."""
44+
"""Load budget config (returns empty dict if absent)."""
3645
try:
37-
if _BUDGET_CONFIG_PATH.exists():
38-
return json.loads(_BUDGET_CONFIG_PATH.read_text())
46+
path = _budget_config_path()
47+
if path.exists():
48+
return json.loads(path.read_text())
3949
except Exception:
4050
logger.debug("Failed to load budget config; treating as empty", exc_info=True)
4151
return {}

effgen/models/cerebras_adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ def _do_generate(
315315
request_params["stop"] = config.stop_sequences
316316
if config.seed is not None:
317317
request_params["seed"] = config.seed
318+
if config.presence_penalty:
319+
request_params["presence_penalty"] = config.presence_penalty
320+
if config.frequency_penalty:
321+
request_params["frequency_penalty"] = config.frequency_penalty
318322

319323
# Attach tools if provided and model supports them
320324
model_info_dict = CEREBRAS_MODELS.get(self.model_name, {})
@@ -516,6 +520,10 @@ def generate_stream(
516520
request_params["stop"] = config.stop_sequences
517521
if config.seed is not None:
518522
request_params["seed"] = config.seed
523+
if config.presence_penalty:
524+
request_params["presence_penalty"] = config.presence_penalty
525+
if config.frequency_penalty:
526+
request_params["frequency_penalty"] = config.frequency_penalty
519527

520528
request_params.update(kwargs)
521529

effgen/models/fireworks_adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ def _do_generate(
313313
request_params["stop"] = config.stop_sequences
314314
if config.seed is not None:
315315
request_params["seed"] = config.seed
316+
if config.presence_penalty:
317+
request_params["presence_penalty"] = config.presence_penalty
318+
if config.frequency_penalty:
319+
request_params["frequency_penalty"] = config.frequency_penalty
316320

317321
info = FIREWORKS_MODELS.get(self.model_name, {})
318322
if tools and info.get("supports_native_tools", False):
@@ -528,6 +532,10 @@ def generate_stream(
528532
request_params["stop"] = config.stop_sequences
529533
if config.seed is not None:
530534
request_params["seed"] = config.seed
535+
if config.presence_penalty:
536+
request_params["presence_penalty"] = config.presence_penalty
537+
if config.frequency_penalty:
538+
request_params["frequency_penalty"] = config.frequency_penalty
531539

532540
request_params.update(kwargs)
533541

effgen/models/gemini_adapter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ def _build_config(self, config: GenerationConfig | None) -> Any:
240240
}
241241
if config.stop_sequences:
242242
kwargs["stop_sequences"] = config.stop_sequences
243+
if config.seed is not None:
244+
kwargs["seed"] = config.seed
245+
if config.presence_penalty:
246+
kwargs["presence_penalty"] = config.presence_penalty
247+
if config.frequency_penalty:
248+
kwargs["frequency_penalty"] = config.frequency_penalty
243249

244250
# Thinking config — only for models that support it.
245251
if config.thinking_budget is not None:

effgen/models/groq_adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,10 @@ def _do_generate(
422422
request_params["stop"] = config.stop_sequences
423423
if config.seed is not None:
424424
request_params["seed"] = config.seed
425+
if config.presence_penalty:
426+
request_params["presence_penalty"] = config.presence_penalty
427+
if config.frequency_penalty:
428+
request_params["frequency_penalty"] = config.frequency_penalty
425429

426430
info = GROQ_MODELS.get(self.model_name, {})
427431
if tools and info.get("supports_native_tools", False):
@@ -682,6 +686,10 @@ def generate_stream(
682686
request_params["stop"] = config.stop_sequences
683687
if config.seed is not None:
684688
request_params["seed"] = config.seed
689+
if config.presence_penalty:
690+
request_params["presence_penalty"] = config.presence_penalty
691+
if config.frequency_penalty:
692+
request_params["frequency_penalty"] = config.frequency_penalty
685693

686694
request_params.update(kwargs)
687695

effgen/models/together_adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ def _do_generate(
373373
request_params["stop"] = config.stop_sequences
374374
if config.seed is not None:
375375
request_params["seed"] = config.seed
376+
if config.presence_penalty:
377+
request_params["presence_penalty"] = config.presence_penalty
378+
if config.frequency_penalty:
379+
request_params["frequency_penalty"] = config.frequency_penalty
376380

377381
info = TOGETHER_MODELS.get(self.model_name, {})
378382
if tools and info.get("supports_native_tools", False):
@@ -623,6 +627,10 @@ def generate_stream(
623627
request_params["stop"] = config.stop_sequences
624628
if config.seed is not None:
625629
request_params["seed"] = config.seed
630+
if config.presence_penalty:
631+
request_params["presence_penalty"] = config.presence_penalty
632+
if config.frequency_penalty:
633+
request_params["frequency_penalty"] = config.frequency_penalty
626634

627635
request_params.update(kwargs)
628636

0 commit comments

Comments
 (0)