Skip to content

Commit facf18f

Browse files
committed
fix(core,rag,models,ui): stop word-count sub-agent fan-out; wire decomposition client; per-page PDF citations
1 parent d980c54 commit facf18f

15 files changed

Lines changed: 335 additions & 90 deletions

effgen/cli/_main.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -881,13 +881,18 @@ def run_agent(self, args):
881881

882882
agent = Agent(agent_config, session_id=getattr(args, 'session_id', None))
883883

884-
# Determine execution mode
885-
mode = AgentMode.AUTO
886-
if args.mode:
887-
if args.mode == "single":
888-
mode = AgentMode.SINGLE
889-
elif args.mode == "sub_agents":
890-
mode = AgentMode.SUB_AGENTS
884+
# Determine execution mode. Default to the agent's own
885+
# config.mode (SINGLE unless set otherwise) instead of forcing
886+
# AUTO, so a plain `effgen run "<task>"` doesn't switch to
887+
# sub-agent decomposition on its own — pass --mode auto to
888+
# opt in explicitly for a call.
889+
mode = None
890+
if args.mode == "single":
891+
mode = AgentMode.SINGLE
892+
elif args.mode == "sub_agents":
893+
mode = AgentMode.SUB_AGENTS
894+
elif args.mode == "auto":
895+
mode = AgentMode.AUTO
891896

892897
# Run task
893898
self.print(f"\n[bold]Task:[/bold] {args.task}" if self.console else f"\nTask: {args.task}")

effgen/core/agent.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,25 @@ class AgentConfig:
178178
temperature: Generation temperature
179179
top_p: Nucleus-sampling threshold; overridden per call by run(top_p=...)
180180
top_k: Top-k sampling cutoff (providers that don't support it ignore it)
181-
seed: Sampling seed; a fixed seed plus temperature=0 reproduces a
182-
generation exactly, on providers that support it
181+
seed: Sampling seed. A fixed seed plus temperature=0 reproduces a
182+
generation exactly on Gemini, Groq, and local engines
183+
(transformers/vllm/gguf/mlx). OpenAI's chat models accept
184+
``seed`` and typically reproduce output, but the same
185+
seed+temperature=0 request can still return a different
186+
completion — OpenAI documents this as best-effort determinism,
187+
not a guarantee, especially for reasoning-tier models. Treat an
188+
OpenAI ``seed`` as "usually reproducible," not "always."
183189
presence_penalty: Penalizes tokens already present anywhere in the text
184190
frequency_penalty: Penalizes tokens proportionally to how often they
185191
already appeared (the standard anti-repetition knob for long text)
186192
repetition_penalty: Multiplicative repeat penalty used by local/HF engines
193+
mode: Default execution mode for run()/run_async() when the call
194+
site doesn't pass its own ``mode=``. Defaults to
195+
``AgentMode.SINGLE`` so a plain ``Agent(config).run(task)`` never
196+
switches to sub-agent decomposition on its own; set to
197+
``AgentMode.AUTO`` to have the router decide per call, or pass
198+
``mode=`` on an individual ``run()`` call to override this
199+
default just for that call.
187200
enable_sub_agents: Enable sub-agent spawning
188201
enable_memory: Enable memory systems
189202
enable_streaming: Enable response streaming
@@ -223,6 +236,7 @@ class AgentConfig:
223236
presence_penalty: float = 0.0
224237
frequency_penalty: float = 0.0
225238
repetition_penalty: float = 1.0
239+
mode: AgentMode = AgentMode.SINGLE
226240
enable_sub_agents: bool = True
227241
enable_memory: bool = True
228242
enable_streaming: bool = False
@@ -472,6 +486,19 @@ def to_dict(self) -> dict[str, Any]:
472486
from .agent_react import AgentReActMixin # noqa: E402
473487

474488

489+
class _AgentDecompositionClient:
490+
"""Adapts an :class:`Agent` to the ``generate(prompt, **kwargs) -> str``
491+
interface :class:`~effgen.core.decomposition_engine.DecompositionEngine`
492+
expects for LLM-assisted task decomposition, by routing through the
493+
agent's own model-generation path (:meth:`Agent._generate`)."""
494+
495+
def __init__(self, agent: "Agent") -> None:
496+
self._agent = agent
497+
498+
def generate(self, prompt: str, **kwargs: Any) -> str:
499+
return self._agent._generate(prompt, **kwargs).get("text", "")
500+
501+
475502
class Agent(AgentGenerationMixin, AgentReActMixin, AgentRuntimeMixin):
476503
"""
477504
Main Agent implementation with ReAct loop and sub-agent support.
@@ -749,7 +776,7 @@ def __init__(self, config: AgentConfig | None = None, session_id: str | None = N
749776
if config.enable_sub_agents:
750777
self.router = SubAgentRouter(
751778
config=config.router_config,
752-
llm_client=self # Pass self as LLM client
779+
llm_client=_AgentDecompositionClient(self)
753780
)
754781
self.sub_agent_manager = SubAgentManager(
755782
parent_agent=self,
@@ -1057,7 +1084,7 @@ def _auto_detect_verbose(self) -> bool:
10571084

10581085
def run(self,
10591086
task: "str | Message | list[Any]",
1060-
mode: AgentMode = AgentMode.AUTO,
1087+
mode: AgentMode | None = None,
10611088
context: dict[str, Any] | None = None,
10621089
output_schema: dict[str, Any] | None = None,
10631090
output_model: Any = None,
@@ -1071,7 +1098,11 @@ def run(self,
10711098
``Message``, or a ``list[ContentPart]``; text is extracted and
10721099
any image/audio/video parts are routed through the multimodal
10731100
path.
1074-
mode: Execution mode (single, sub_agents, auto)
1101+
mode: Execution mode (single, sub_agents, auto). Defaults to
1102+
``self.config.mode`` (``AgentMode.SINGLE`` unless the config
1103+
sets otherwise) when omitted — pass ``mode=AgentMode.AUTO``
1104+
to let the router decide based on task complexity for this
1105+
call only.
10751106
context: Optional context
10761107
output_schema: A JSON-Schema ``dict`` **or** a Pydantic
10771108
``BaseModel`` subclass — when provided, the final output is
@@ -1095,6 +1126,8 @@ def run(self,
10951126
Returns:
10961127
AgentResponse with results
10971128
"""
1129+
if mode is None:
1130+
mode = self.config.mode
10981131
unrecognized = {k for k in kwargs if k not in _RUN_KWARGS and not k.startswith("_")}
10991132
if unrecognized:
11001133
bad = sorted(unrecognized)[0]
@@ -1748,7 +1781,7 @@ def synthesize(self, synthesis_data: dict[str, Any]) -> str:
17481781

17491782
async def run_async(self,
17501783
task: "str | Message | list[Any]",
1751-
mode: AgentMode = AgentMode.AUTO,
1784+
mode: AgentMode | None = None,
17521785
context: dict[str, Any] | None = None,
17531786
output_schema: dict[str, Any] | None = None,
17541787
output_model: Any = None,
@@ -1763,7 +1796,8 @@ async def run_async(self,
17631796
17641797
Args:
17651798
task: Task description (str, Message, or list[ContentPart])
1766-
mode: Execution mode
1799+
mode: Execution mode. Defaults to ``self.config.mode`` when
1800+
omitted (see ``run()``).
17671801
context: Optional context
17681802
output_schema: A JSON-Schema ``dict`` or a Pydantic ``BaseModel``
17691803
subclass (see ``run``).
@@ -1911,7 +1945,7 @@ def _stream_direct(self, task: str, on_answer: Callable[[str], None] | None = No
19111945

19121946
def stream(self,
19131947
task: "str | Message | list[Any]",
1914-
mode: AgentMode = AgentMode.AUTO,
1948+
mode: AgentMode | None = None,
19151949
context: dict[str, Any] | None = None,
19161950
on_thought: Callable[[str], None] | None = None,
19171951
on_tool_call: Callable[[str, str], None] | None = None,

effgen/core/agent_react.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,16 @@ def _attach_citations(self, response: "AgentResponse") -> None:
11711171
``web_search_call`` action sources). Those widen ``response.sources``
11721172
so a search that ran is never left unsourced, but they never
11731173
manufacture a ``Citation`` entry the model did not actually make.
1174+
1175+
Locally-mined URL sources (``_collect_citations``, e.g. a plain
1176+
``web_search`` tool) carry no such explicit marker; a search can
1177+
return results the model never draws on. For those, "cited" is
1178+
determined by whether the model's final answer text actually
1179+
contains the URL — mirroring the native-provider distinction above.
1180+
Non-URL sources (e.g. a RAG file path) use inline ``[N]`` bracket
1181+
markers this generic mining path can't reliably map back to one
1182+
item across multiple tool calls, so they keep the previous default
1183+
(cited=True) rather than risk dropping a real citation.
11741184
"""
11751185
raw = list(getattr(self, "_collected_citations", None) or [])
11761186
# Fold provider-native grounding chunks ({url, title}) into the same
@@ -1196,12 +1206,20 @@ def _attach_citations(self, response: "AgentResponse") -> None:
11961206

11971207
from ..rag.attribution import Citation
11981208

1209+
answer_text = response.output or ""
11991210
seen: set[tuple[str, str, str]] = set()
12001211
citations: list[Citation] = []
12011212
sources: list[str] = []
12021213
seen_sources: set[str] = set()
12031214
for entry in raw:
1204-
if entry.get("cited", True):
1215+
is_cited = entry.get("cited")
1216+
if is_cited is None:
1217+
source = entry["source"]
1218+
if source.startswith(("http://", "https://")):
1219+
is_cited = source in answer_text
1220+
else:
1221+
is_cited = True
1222+
if is_cited:
12051223
key = (entry["source"], entry["chunk_id"], entry["quote"][:80])
12061224
if key not in seen:
12071225
seen.add(key)

effgen/core/complexity_analyzer.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,17 @@ def _count_requirements(self, task: str) -> int:
212212
# Count questions
213213
num_questions = task.count("?")
214214

215-
# Count "and" clauses (heuristic for multiple requirements)
216-
num_and_clauses = task.lower().count(" and ")
215+
# Count "and" clauses (heuristic for multiple requirements), scaled
216+
# by task length. A short task with several "and"s describes several
217+
# sequential asks ("fetch the data and convert it and email me the
218+
# result"); a long passage — e.g. one instruction plus a pasted
219+
# reference document such as a paper abstract — accumulates "and"s in
220+
# ordinary prose without describing separate requirements. Density,
221+
# not the raw count, is the signal, so the contribution tapers off
222+
# for longer text instead of scaling unbounded with length.
223+
words = task.split()
224+
raw_and_clauses = task.lower().count(" and ")
225+
num_and_clauses = round(raw_and_clauses * min(1.0, 30 / max(len(words), 1)))
217226

218227
# Count numbered items
219228
num_numbered = len(re.findall(r'\d+[\.)]\s', task))

effgen/core/decomposition_engine.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class DecompositionEngine:
7979
3. Subtasks should not depend on each other
8080
4. Cover all aspects of the original task
8181
5. Be specific about what each subtask should accomplish
82+
6. required_specialization must be exactly one of: general, research, coding, analysis, synthesis, data, creative
8283
8384
Format your response as JSON:
8485
{{
@@ -88,7 +89,7 @@ class DecompositionEngine:
8889
"description": "Detailed description of what to do",
8990
"expected_output": "What this subtask should produce",
9091
"estimated_complexity": 5.0,
91-
"required_specialization": "research|coding|analysis|synthesis",
92+
"required_specialization": "research",
9293
"priority": "high|medium|low"
9394
}}
9495
],
@@ -107,6 +108,7 @@ class DecompositionEngine:
107108
3. Show dependencies clearly
108109
4. Cover all aspects of the original task
109110
5. Be specific about inputs and outputs
111+
6. required_specialization must be exactly one of: general, research, coding, analysis, synthesis, data, creative
110112
111113
Format your response as JSON:
112114
{{
@@ -117,7 +119,7 @@ class DecompositionEngine:
117119
"depends_on": [],
118120
"expected_output": "What this subtask should produce",
119121
"estimated_complexity": 5.0,
120-
"required_specialization": "research|coding|analysis|synthesis",
122+
"required_specialization": "research",
121123
"priority": "high|medium|low"
122124
}}
123125
],
@@ -136,6 +138,7 @@ class DecompositionEngine:
136138
3. Optimize for both speed and dependencies
137139
4. Cover all aspects of the original task
138140
5. Be specific about what each subtask should accomplish
141+
6. required_specialization must be exactly one of: general, research, coding, analysis, synthesis, data, creative
139142
140143
Format your response as JSON:
141144
{{
@@ -146,7 +149,7 @@ class DecompositionEngine:
146149
"depends_on": ["st_0"],
147150
"expected_output": "What this subtask should produce",
148151
"estimated_complexity": 5.0,
149-
"required_specialization": "research|coding|analysis|synthesis",
152+
"required_specialization": "research",
150153
"priority": "high|medium|low"
151154
}}
152155
],
@@ -332,13 +335,28 @@ def _parse_decomposition(self, response: str) -> list[SubTask]:
332335
}
333336
priority = priority_map.get(priority_str, TaskPriority.MEDIUM)
334337

338+
# Normalize the model's free-text specialization to one of the
339+
# known values (see SubAgentSpecialization) before it reaches
340+
# sub-agent dispatch. A model does not always reproduce the
341+
# prompt's example format exactly (e.g. echoing the pipe-joined
342+
# placeholder list itself, or inventing a value like
343+
# "data_processing"); default to "general" instead of letting an
344+
# unrecognized value fail the whole run downstream.
345+
valid_specializations = {
346+
"general", "research", "coding", "analysis", "synthesis", "data", "creative",
347+
}
348+
raw_specialization = subtask_data.get("required_specialization")
349+
specialization = (raw_specialization or "").strip().lower()
350+
if specialization not in valid_specializations:
351+
specialization = "general"
352+
335353
# Create SubTask
336354
subtask = SubTask(
337355
id=subtask_data.get("id", f"st_{i+1}"),
338356
description=subtask_data["description"],
339357
expected_output=subtask_data["expected_output"],
340358
estimated_complexity=subtask_data.get("estimated_complexity", 5.0),
341-
required_specialization=subtask_data.get("required_specialization"),
359+
required_specialization=specialization,
342360
depends_on=subtask_data.get("depends_on", []),
343361
metadata={
344362
"reasoning": data.get("reasoning", ""),

effgen/core/router.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,19 @@ def should_use_sub_agents(self, task: str, context: dict[str, Any] | None = None
168168
strategy = RoutingStrategy.SINGLE_AGENT
169169

170170
# Generate reasoning
171-
reasoning = (
172-
f"Complexity score: {complexity_score.overall:.2f}. "
173-
f"{'Sub-agents recommended' if use_sub_agents else 'Single agent sufficient'} "
174-
f"based on complexity threshold of {self.config['complexity_threshold']}."
175-
)
171+
num_requirements = complexity_score.breakdown.get("num_requirements", 1)
172+
if use_sub_agents:
173+
reasoning = (
174+
f"Complexity score: {complexity_score.overall:.2f}. Sub-agents "
175+
f"recommended: an explicit request, a multi-step keyword phrase, "
176+
f"or {num_requirements} distinct requirements indicate a compound task."
177+
)
178+
else:
179+
reasoning = (
180+
f"Complexity score: {complexity_score.overall:.2f}. Single agent "
181+
f"sufficient: no explicit request, multi-step keyword phrase, or "
182+
f"multiple distinct requirements — the task is a single unit of work."
183+
)
176184

177185
return RoutingDecision(
178186
use_sub_agents=use_sub_agents,
@@ -288,12 +296,19 @@ def _should_use_sub_agents(self, complexity_score: ComplexityScore, task: str) -
288296
"""
289297
Decision criteria for using sub-agents.
290298
291-
Factors considered:
299+
Factors considered, in order:
292300
1. User-explicit request (always honored)
293-
2. Complexity score vs threshold
294-
3. Presence of keyword triggers
295-
4. Task length and structure
296-
5. Number of requirements
301+
2. Presence of keyword triggers (an explicit multi-step phrase)
302+
3. Number of distinct requirements (structural compoundness)
303+
304+
The raw complexity ``overall`` score is not used as an independent
305+
trigger: it is weighted by task length and domain/tool vocabulary
306+
breadth as much as by actual task structure, so a single dense
307+
instruction — or a short instruction plus a long pasted reference
308+
document (a paper abstract, a log excerpt) — can score as "complex"
309+
without describing more than one thing to do. Decomposition is
310+
warranted by evidence the task is genuinely *compound*, not merely
311+
long or information-dense.
297312
298313
Args:
299314
complexity_score: Calculated complexity score
@@ -307,31 +322,19 @@ def _should_use_sub_agents(self, complexity_score: ComplexityScore, task: str) -
307322
logger.debug("User explicitly requested sub-agents — honoring request.")
308323
return True
309324

310-
# Complexity threshold check
311-
threshold = self.config["complexity_threshold"]
312-
if complexity_score.overall < threshold:
313-
# Check if triggers present that override threshold
314-
if not self._check_keyword_triggers(task):
315-
return False
316-
317-
# Keyword trigger check
325+
# Keyword trigger check — an explicit multi-step phrase ("research
326+
# and analyze", "compare multiple", "comprehensive", ...) is a
327+
# reliable compound-task signal regardless of the raw score.
318328
if self._check_keyword_triggers(task):
319329
return True
320330

321-
# Multiple requirements check
331+
# Multiple requirements check — several questions, numbered/bulleted
332+
# items, semicolon-separated asks, or "and"-joined requests are a
333+
# structural signal the task actually has multiple parts.
322334
num_requirements = complexity_score.breakdown.get("num_requirements", 1)
323335
if num_requirements >= self.config["min_subtasks"]:
324336
return True
325337

326-
# Task length check (very long tasks benefit from decomposition)
327-
word_count = complexity_score.breakdown.get("word_count", 0)
328-
if word_count > 100:
329-
return True
330-
331-
# High complexity check (override threshold with margin)
332-
if complexity_score.overall >= threshold * 0.9:
333-
return True
334-
335338
return False
336339

337340
def _check_keyword_triggers(self, task: str) -> bool:

0 commit comments

Comments
 (0)