Skip to content

Commit 994e200

Browse files
committed
fix(dx): newcomer ergonomics — tool names, teaching errors, no scaffold leaks
1 parent de01591 commit 994e200

10 files changed

Lines changed: 430 additions & 41 deletions

effgen/cli/_main.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,19 @@ def run_agent(self, args):
711711

712712
self.print_header(f"effGen v{__version__} - Running Task")
713713

714+
# Resolve the model once so the preset and plain paths agree. With no
715+
# -m/--model, mirror `quickstart`: prefer a detected cheap cloud model,
716+
# else a small local model — and say why, so the choice is never a silent
717+
# surprise (a paid cloud call or a multi-GB local download).
718+
if args.model:
719+
run_model = args.model
720+
_preflight_model_hint(self, run_model, provider)
721+
else:
722+
run_model, _sugg_provider, _sugg_reason = _quickstart_suggest_model()
723+
if provider is None and _sugg_provider:
724+
provider = _sugg_provider
725+
self.print(f"Using model {run_model} ({_sugg_reason}); override with -m/--model.")
726+
714727
agent = None
715728
try:
716729
# Load configuration if provided
@@ -728,7 +741,7 @@ def run_agent(self, args):
728741
# Use preset if specified
729742
if getattr(args, 'preset', None):
730743
from effgen.presets import create_agent as _create_preset_agent
731-
model_id = args.model or "Qwen/Qwen2.5-3B-Instruct"
744+
model_id = run_model
732745
self.print(f"Using preset: {args.preset}")
733746
_preset_overrides = {"provider": provider} if provider else {}
734747
agent = _create_preset_agent(
@@ -777,7 +790,7 @@ def run_agent(self, args):
777790
# Create agent configuration
778791
agent_config = AgentConfig(
779792
name=args.name or "cli-agent",
780-
model=args.model or "Qwen/Qwen2.5-3B-Instruct",
793+
model=run_model,
781794
provider=provider,
782795
tools=tools,
783796
system_prompt=args.system_prompt or config.get("system_prompt",
@@ -3786,6 +3799,40 @@ def _quickstart_suggest_model() -> tuple[str, str | None, str]:
37863799
return _QUICKSTART_LOCAL_MODEL, None, "no cloud key found — using a small local model"
37873800

37883801

3802+
def _preflight_model_hint(cli: "CLIInterface", model_id: str, provider: str | None) -> None:
3803+
"""Surface a clean "did you mean" for an unknown model id, once, up front.
3804+
3805+
When the user passes an explicit ``-m`` id that isn't in the local catalog,
3806+
a high-confidence typo (e.g. ``gpt-5-nanoo``) otherwise only reveals itself
3807+
mid-run as a provider 404 wall. This checks the local catalog first and, if
3808+
the id is unknown but has near matches, prints a single tidy suggestion line.
3809+
It never blocks — the catalog can be stale, so an unknown-but-real new id is
3810+
allowed through; we only inform.
3811+
"""
3812+
try:
3813+
from effgen.models import _catalog
3814+
3815+
_bare = model_id.split(":", 1)[-1]
3816+
# Local / HF-hub ids ("org/model") are resolved by download, not via the
3817+
# cloud catalog, so a catalog miss there is meaningless — never warn on a
3818+
# legitimate local model (e.g. meta-llama/Llama-3.2-3B-Instruct). The hint
3819+
# targets slash-free cloud chat ids, where a typo is the likely cause.
3820+
if "/" in _bare:
3821+
return
3822+
if _catalog.lookup(model_id, provider) is not None:
3823+
return
3824+
alts = _catalog.nearest_alternatives(model_id, provider, n=3)
3825+
if not alts:
3826+
return
3827+
names = ", ".join(r.id for r in alts)
3828+
cli.print(
3829+
f"Note: '{_bare}' isn't in the local catalog. Did you mean: {names}? "
3830+
"Proceeding anyway — run 'effgen models refresh' if it's a new id."
3831+
)
3832+
except Exception: # noqa: BLE001 - a hint must never break the run
3833+
pass
3834+
3835+
37893836
def _handle_quickstart_command(args, cli: "CLIInterface") -> int:
37903837
"""Handle 'effgen quickstart' / 'effgen tutorial' — a guided first run.
37913838
@@ -3903,17 +3950,19 @@ def _handle_quickstart_command(args, cli: "CLIInterface") -> int:
39033950
return 1
39043951

39053952
# --- 4. Show the trace -------------------------------------------
3906-
if response.execution_trace:
3907-
cli.print_header("What the agent did")
3908-
for i, step in enumerate(response.execution_trace, 1):
3909-
action = step.get("action", step.get("tool", ""))
3910-
observation = step.get("observation", step.get("output", ""))
3911-
if action:
3912-
cli.print(f" {i}. used [cyan]{action}[/cyan] → {str(observation)[:80]}"
3913-
if cli.console else
3914-
f" {i}. used {action} -> {str(observation)[:80]}")
3915-
if not any(s.get("action") or s.get("tool") for s in response.execution_trace):
3916-
cli.print(" (answered directly, no tools needed)")
3953+
# The agent's execution_trace is a list of event dicts (type/message/
3954+
# data) — not a ReAct action/observation shape — so render it with the
3955+
# shared formatter that understands those events. Hand-reading
3956+
# ``step["action"]`` here used to miss every tool call and wrongly print
3957+
# "answered directly" even when a tool ran (and tool_calls reported it).
3958+
cli.print_header("What the agent did")
3959+
_trace_lines = _progress.execution_trace_lines(response.execution_trace)
3960+
if _trace_lines:
3961+
for _style, _text in _trace_lines:
3962+
if cli.console:
3963+
cli.console.print(f" [{_style}]{_text}[/{_style}]")
3964+
else:
3965+
cli.print(f" {_text}")
39173966
else:
39183967
cli.print(" (answered directly, no tools needed)")
39193968

effgen/core/agent.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ class Agent(AgentGenerationMixin, AgentReActMixin, AgentRuntimeMixin):
439439
Question: {task}
440440
{scratchpad}"""
441441

442-
def __init__(self, config: AgentConfig, session_id: str | None = None):
442+
def __init__(self, config: AgentConfig | None = None, session_id: str | None = None):
443443
"""
444444
Initialize agent.
445445
@@ -449,9 +449,16 @@ def __init__(self, config: AgentConfig, session_id: str | None = None):
449449
agent loads/creates a Session in ~/.effgen/sessions/ and
450450
appends each run() turn to it.
451451
"""
452-
# Fail fast on the bare-constructor trap: ``Agent("groq:model")`` stores
453-
# the str unvalidated and only crashes deep in run(). Point users at the
454-
# real construction paths instead.
452+
# Fail fast on the bare-constructor traps and teach the fix. ``Agent()``
453+
# (no args) and ``Agent("groq:model")`` (a bare id) would otherwise be a
454+
# cryptic "missing argument" / a str stored unvalidated that crashes deep
455+
# in run(). Point users at the real construction paths instead.
456+
if config is None:
457+
raise TypeError(
458+
"Agent() needs an AgentConfig. Build one with "
459+
"AgentConfig(name=..., model=...), or use the preset helper "
460+
"create_agent('minimal', 'groq:llama-3.1-8b-instant')."
461+
)
455462
if not isinstance(config, AgentConfig):
456463
raise TypeError(
457464
"Agent(config=...) expects an AgentConfig, not "
@@ -501,6 +508,9 @@ def __init__(self, config: AgentConfig, session_id: str | None = None):
501508
if config.require_model:
502509
# Fail fast on a typo'd id / missing key rather than
503510
# returning a working-looking agent (see AgentConfig docs).
511+
# Mark closed first so this never-returned, partially-built
512+
# agent doesn't tail the error with a GC-without-close warning.
513+
self._closed = True
504514
logger.error(f"Failed to load model '{self.model_name}': {e}")
505515
raise RuntimeError(
506516
f"Failed to load model '{self.model_name}': {e}"
@@ -515,6 +525,7 @@ def __init__(self, config: AgentConfig, session_id: str | None = None):
515525
# BaseModel (e.g. an int, a list, or a Pydantic class). Fail fast
516526
# with a clear message instead of silently building a model-less
517527
# agent that only crashes at run().
528+
self._closed = True
518529
raise TypeError(
519530
f"AgentConfig.model must be a model-id str or a loaded model "
520531
f"instance, not {type(config.model).__name__}. "
@@ -528,6 +539,7 @@ def __init__(self, config: AgentConfig, session_id: str | None = None):
528539
# Fail at construction (not on the first run()) so a model-less
529540
# agent doesn't look usable. Set require_model=False to defer
530541
# model assignment (advanced/sub-agent use).
542+
self._closed = True
531543
raise ValueError(
532544
f"Agent '{config.name}' was created without a model. "
533545
"Pass a model id or instance, e.g. "

effgen/core/agent_generation.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ def _generate(self, prompt: Any, **kwargs) -> dict[str, Any]:
240240

241241
last_error = None
242242
truncation_detail: dict[str, Any] | None = None
243+
# A non-retryable failure (auth/not-found/invalid) is already logged once,
244+
# in full, at ERROR below. Track that so the final summary doesn't re-log
245+
# the same failure a second time at WARNING (the "logged 3×" noise).
246+
nonretryable_logged = False
243247
total_tokens = 0
244248
# Whether the caller pinned max_tokens; if they didn't we may grow the
245249
# budget to recover a reasoning model from a "length"-truncated empty.
@@ -355,6 +359,7 @@ def _generate(self, prompt: Any, **kwargs) -> dict[str, Any]:
355359
getattr(current_model, "model_name", "?"),
356360
e,
357361
)
362+
nonretryable_logged = True
358363
break # stop retrying this model; outer loop may failover
359364
if attempt < max_retries - 1:
360365
logger.warning(
@@ -388,7 +393,11 @@ def _generate(self, prompt: Any, **kwargs) -> dict[str, Any]:
388393
"message": "Model returned an empty response after retries.",
389394
"retryable": True,
390395
}
391-
logger.warning("Generation failed: %s", detail["message"])
396+
# Avoid re-logging a non-retryable failure already reported at ERROR above.
397+
if nonretryable_logged:
398+
logger.debug("Generation failed: %s", detail["message"])
399+
else:
400+
logger.warning("Generation failed: %s", detail["message"])
392401
return {
393402
"text": "",
394403
"tokens_used": total_tokens,

effgen/core/agent_react.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
from .agent_runtime import ( # noqa: E402
4747
_SCAFFOLD_LITERAL_RES,
4848
_TOOL_ECHO_RE,
49+
NUDGE_ALREADY_COMPUTED,
50+
NUDGE_CONTINUE,
51+
NUDGE_HAVE_ANSWER,
52+
NUDGE_HAVE_RESULTS,
53+
NUDGE_NO_TOOLS,
54+
NUDGE_NOT_USABLE,
4955
_infer_provider_from_model,
5056
sanitize_final_answer,
5157
)
@@ -290,7 +296,7 @@ def _run_single_agent(self,
290296
else:
291297
batch_observations.append(f"[{_tname}] → Tool not found")
292298
# After batch execution, nudge model to synthesize a final answer.
293-
scratchpad += "\n[Tool results computed above. Continue or provide Final Answer:]"
299+
scratchpad += f"\n{NUDGE_CONTINUE}"
294300
_batch_tool_runs += 1
295301
parsed = {"thought": "", "action": None, "action_input": None, "final_answer": None}
296302
cur_observation = "\n".join(batch_observations)
@@ -371,10 +377,7 @@ def _build_response(
371377
logger.info(
372378
"Discarding scaffolding-only final answer; continuing loop"
373379
)
374-
scratchpad += (
375-
"\nObservation: That was not a usable answer. "
376-
"Call the tool correctly or give a plain Final Answer."
377-
)
380+
scratchpad += f"\nObservation: {NUDGE_NOT_USABLE}"
378381
final_answer = None
379382

380383
if final_answer:
@@ -455,8 +458,7 @@ def _build_response(
455458
scratchpad += (
456459
f"\nAction: {action}"
457460
f"\nAction Input: {action_input}"
458-
"\nObservation: You already computed this. "
459-
"Please provide your final response using 'Final Answer:' now."
461+
f"\nObservation: {NUDGE_ALREADY_COMPUTED}"
460462
)
461463
continue
462464

@@ -468,7 +470,7 @@ def _build_response(
468470
# Guide it to provide direct answer
469471
scratchpad += f"\nAction: {action}"
470472
scratchpad += f"\nAction Input: {action_input}"
471-
scratchpad += "\nObservation: No tools available. Please provide your answer directly using 'Final Answer:'."
473+
scratchpad += f"\nObservation: {NUDGE_NO_TOOLS}"
472474
else:
473475
# Execute tool inside tracing span
474476
tool_start = time.time()
@@ -531,15 +533,12 @@ def _build_response(
531533

532534
# Nudge model to answer when iterations are running low
533535
if iterations >= self.config.max_iterations - 2:
534-
scratchpad += "\n[You have the answer from the tool. Please respond with 'Final Answer:' now.]"
536+
scratchpad += f"\n{NUDGE_HAVE_ANSWER}"
535537
elif action_call_count >= 1 and not tool_result.startswith("Error executing tool"):
536538
# This tool has now run at least twice. The needed data is
537539
# almost certainly in the scratchpad — nudge toward a final
538540
# answer before the loop drifts into repeated re-planning.
539-
scratchpad += (
540-
"\n[You already have results from this tool. If you have "
541-
"enough information, respond now with 'Final Answer:'.]"
542-
)
541+
scratchpad += f"\n{NUDGE_HAVE_RESULTS}"
543542

544543
else:
545544
# No action specified, prompt to continue

effgen/core/agent_runtime.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,42 @@ def _safe_float_or_none(value: Any) -> float | None:
9393
return None
9494

9595

96-
# Literal loop-bookkeeping strings the ReAct scaffolding/prompts inject into the
97-
# scratchpad. These must never reach a user-facing answer. An adjacent newline is
98-
# consumed when present so removing a mid-line marker doesn't leave an orphan line.
96+
# Loop-bookkeeping nudges the ReAct scaffolding injects into the scratchpad to
97+
# steer a stalling model toward a final answer. They are defined here, in ONE
98+
# place, and imported by the injection sites (see ``agent_react``) so the set of
99+
# strings a loop can append can never drift out of sync with the set of strings
100+
# stripped from the user-facing answer below. Adding a new nudge => add a
101+
# constant here and reference it at the injection site; it is then stripped for
102+
# free. The injection sites prepend "\n" (and sometimes "Observation: "), which
103+
# the strip patterns tolerate.
104+
NUDGE_CONTINUE = "[Tool results computed above. Continue or provide Final Answer:]"
105+
NUDGE_HAVE_ANSWER = "[You have the answer from the tool. Please respond with 'Final Answer:' now.]"
106+
NUDGE_HAVE_RESULTS = (
107+
"[You already have results from this tool. If you have "
108+
"enough information, respond now with 'Final Answer:'.]"
109+
)
110+
NUDGE_ALREADY_COMPUTED = (
111+
"You already computed this. Please provide your final response "
112+
"using 'Final Answer:' now."
113+
)
114+
NUDGE_NO_TOOLS = "No tools available. Please provide your answer directly using 'Final Answer:'."
115+
NUDGE_NOT_USABLE = (
116+
"That was not a usable answer. Call the tool correctly or give a "
117+
"plain Final Answer."
118+
)
119+
120+
# Literal loop-bookkeeping strings to strip — every injectable nudge above, plus a
121+
# couple of defensive bracketed/sub-phrase variants. These must never reach a
122+
# user-facing answer. An adjacent newline is consumed when present so removing a
123+
# mid-line marker doesn't leave an orphan line.
99124
_SCAFFOLD_LITERALS: tuple[str, ...] = (
100-
"[Tool results computed above. Continue or provide Final Answer:]",
101-
"[You have the answer from the tool. Please respond with 'Final Answer:' now.]",
102-
"[You already computed this. Please provide your final response using 'Final Answer:' now.]",
103-
"You already computed this. Please provide your final response using 'Final Answer:' now.",
104-
"No tools available. Please provide your answer directly using 'Final Answer:'.",
125+
NUDGE_CONTINUE,
126+
NUDGE_HAVE_ANSWER,
127+
NUDGE_HAVE_RESULTS,
128+
f"[{NUDGE_ALREADY_COMPUTED}]",
129+
NUDGE_ALREADY_COMPUTED,
130+
NUDGE_NO_TOOLS,
131+
NUDGE_NOT_USABLE,
105132
"Please provide your final response using 'Final Answer:' now.",
106133
)
107134
_SCAFFOLD_LITERAL_RES: tuple[re.Pattern[str], ...] = tuple(

0 commit comments

Comments
 (0)