mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
* feat(settings): opt-in reasoning effort for utility model calls Add utility_reasoning_effort (empty = provider default) and let utility calls inherit a chat session's effort only on that session's matching endpoint+model route. Explicit caller effort wins; otherwise the utility setting wins over session inheritance on utility-chain routes. Resolve the chain and setting per user and validate levels against model evidence. No-session work uses the setting only, with no chat-effort crossover. - src/utility_effort.py owns effort_for_call and candidate_effort_factory - remove global effort fill from llm_call/llm_call_async; utility call sites pass effort explicitly, so unrelated calls on the same route do not pick it up - wire auto-title, memory/persona/skill extraction, memory suggest/import/ audit, compaction, auto-sort, and per-owner background task candidates - wire no-session email writing-style, summary, and translation calls, plus skills evaluation, necessity/retrieval audits, and improvement - deliberately exclude email replies because thinking_mode='off' - add the optional candidate_request_factory to llm_call_async_with_fallback - add sync llm_call reasoning_effort and include it in the cache key - show the Utility Model effort selector only for advertised levels Fixes #6569 * fix(utility): exclude email replies and support inherited effort in settings Pass reasoning_effort='' to task_llm_call_async for scheduled reply drafting to honor the email reply exclusion. In AI settings, resolve the effective Default Chat endpoint and model when Utility is set to 'Same as chat' so supported reasoning effort levels can be selected. * feat(utility): support same-as-chat effort and session model fallback in compaction In AI settings, show 'Same as chat' as the default effort option when Utility Model is inherited, and support an explicit 'Provider default' (off) override. Pass the active session fallback in context compaction callers so compaction shares the live chat session's model and reasoning effort for maximum prompt/KV cache affinity. * fix(settings): clarify chat effort inheritance and keep option visible Label inherited effort as 'Same as chat (when supported)' and mention cache affinity and latency speedups in the help text. Ensure the effort control remains visible when the utility model is set to 'Same as chat' even if the default chat model lacks advertised reasoning levels, and populate candidate levels across configured endpoints so the user can configure effort or keep provider default. * feat(settings): per-fallback reasoning effort for utility model Each utility fallback row gets an Effort select, shown only when the selected model advertises reasoning levels. The choice is stored as reasoning_effort on the utility_model_fallbacks entry and sent only to that candidate; an empty value means provider default. * fix(utility): transmit reasoning effort in non-streaming calls --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
|
skill_extractor.py
|
|
|
|
Background auto-extraction of skills from complex agent runs.
|
|
When the agent takes >= 2 rounds or >= 2 tool calls to complete a task,
|
|
we ask the LLM to distill the approach into a reusable skill.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SKILL_EXTRACT_PROMPT = (
|
|
"You are analyzing an AI agent's work session. The agent took {rounds} rounds "
|
|
"and {tool_count} tool calls to complete the task.\n\n"
|
|
"Extract a reusable 'skill' ONLY IF the session contains a concrete, "
|
|
"repeatable procedure the agent could follow to solve a similar problem "
|
|
"ON THE COMPUTER next time (e.g. a sequence of shell commands, code, file "
|
|
"edits, API calls, or tool usage).\n\n"
|
|
"Return null (the bare word, no JSON) when the session is NOT a reusable "
|
|
"computer procedure, including:\n"
|
|
"- The real work happened OUTSIDE the computer (the user did something "
|
|
"physically, in person, on another device, or by hand) and the agent only "
|
|
"discussed or advised it.\n"
|
|
"- A one-off, personal, or context-specific task that won't recur "
|
|
"(personal errands, a specific person/place/date, casual conversation).\n"
|
|
"- A pure question/answer or explanation with no transferable method.\n"
|
|
"- The agent failed, gave up, or the approach is not worth repeating.\n\n"
|
|
"- Routine use of an existing tool, or a generic checklist with no new discovery.\n"
|
|
"Prefer a specific successful workaround, an unexpected pitfall, or a verified "
|
|
"sequence that would save rediscovery. Preserve exact useful commands and "
|
|
"verification steps, but replace private identifiers and credentials with placeholders.\n\n"
|
|
"When (and only when) a genuine reusable procedure exists, return a JSON "
|
|
"object with:\n"
|
|
'- "title": short name (under 10 words)\n'
|
|
'- "problem": what was the challenge (1-2 sentences)\n'
|
|
'- "solution": what worked (1-2 sentences)\n'
|
|
'- "steps": array of step-by-step instructions (3-7 short steps)\n'
|
|
'- "tags": array of relevant keywords (3-5 tags)\n'
|
|
'- "confidence": 0.0-1.0 how reliable AND reusable this procedure is\n\n'
|
|
"Be conservative: if in doubt, return null.\n"
|
|
"Return ONLY valid JSON (or the bare word null), no markdown fences."
|
|
)
|
|
|
|
# Skills the model is unsure about (or that read as one-offs) add clutter —
|
|
# drop anything below this confidence.
|
|
MIN_CONFIDENCE = 0.6
|
|
|
|
# How many recent messages to include
|
|
CONTEXT_WINDOW = 12
|
|
|
|
|
|
def _skill_dicts(skills):
|
|
for skill in skills or []:
|
|
if isinstance(skill, dict):
|
|
yield skill
|
|
|
|
|
|
def _has_duplicate_title(skills, title: str) -> bool:
|
|
wanted = title.lower()
|
|
for skill in _skill_dicts(skills):
|
|
existing = skill.get("title", "")
|
|
if isinstance(existing, str) and existing.lower() == wanted:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _extract_json_object(text: str) -> Optional[dict]:
|
|
"""Best-effort extraction of a JSON object from an LLM response.
|
|
|
|
The response may be wrapped in code fences or surrounded by prose. Uses
|
|
json.JSONDecoder().raw_decode() to locate the boundaries of complete JSON
|
|
objects starting at each '{' position. Nested objects are filtered out to
|
|
keep only top-level candidates. If multiple non-overlapping valid JSON
|
|
objects are found, it is treated as ambiguous and returns None. Otherwise,
|
|
returns the single valid candidate dictionary.
|
|
"""
|
|
if not text:
|
|
return None
|
|
s = text.strip()
|
|
if s.startswith("```"):
|
|
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
|
|
|
decoder = json.JSONDecoder()
|
|
candidates = []
|
|
|
|
start = s.find("{")
|
|
while start != -1:
|
|
try:
|
|
obj, idx = decoder.raw_decode(s[start:])
|
|
end_pos = start + idx
|
|
if isinstance(obj, dict):
|
|
candidates.append((start, end_pos, obj))
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
start = s.find("{", start + 1)
|
|
|
|
# Filter out nested candidates to identify top-level dictionaries
|
|
top_level = []
|
|
for c in candidates:
|
|
is_nested = False
|
|
for other in candidates:
|
|
if other == c:
|
|
continue
|
|
if other[0] <= c[0] and c[1] <= other[1]:
|
|
is_nested = True
|
|
break
|
|
if not is_nested:
|
|
top_level.append(c)
|
|
|
|
if not top_level:
|
|
return None
|
|
|
|
if len(top_level) > 1:
|
|
logger.debug(
|
|
"[skill-extract] Found multiple non-overlapping JSON objects: %s",
|
|
[item[2].get("title") for item in top_level]
|
|
)
|
|
return None
|
|
|
|
return top_level[0][2]
|
|
|
|
|
|
async def maybe_extract_skill(
|
|
session,
|
|
skills_manager,
|
|
endpoint_url: str,
|
|
model: str,
|
|
headers: dict,
|
|
round_count: int,
|
|
tool_count: int,
|
|
owner: Optional[str] = None,
|
|
):
|
|
"""Extract a skill if the agent run was complex enough."""
|
|
if not model:
|
|
logger.debug("[skill-extract] No model provided, skipping")
|
|
return None
|
|
|
|
# Quiet by default; flip to DEBUG when chasing extractor issues.
|
|
logger.debug(
|
|
"[skill-extract] start: rounds=%d tools=%d model=%s owner=%s",
|
|
round_count, tool_count, model, owner,
|
|
)
|
|
if round_count < 2 and tool_count < 2:
|
|
logger.debug("[skill-extract] BELOW threshold (need rounds>=2 or tools>=2)")
|
|
return None
|
|
|
|
try:
|
|
from src.llm_core import llm_call_async
|
|
from src.utility_effort import effort_for_call
|
|
|
|
# Get recent messages
|
|
history = session.get_context_messages()
|
|
recent = history[-CONTEXT_WINDOW:] if len(history) > CONTEXT_WINDOW else history
|
|
if not recent:
|
|
logger.debug("[skill-extract] no recent messages, skipping")
|
|
return None
|
|
|
|
# Strip media (images/audio) from messages
|
|
stripped_recent = []
|
|
for msg in recent:
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
text_only = [b for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
|
if not text_only and content:
|
|
continue
|
|
content = text_only
|
|
stripped_recent.append({"role": msg.get("role"), "content": content})
|
|
|
|
if not stripped_recent:
|
|
return None
|
|
|
|
# Build conversation summary for extraction
|
|
conv_lines = []
|
|
for msg in stripped_recent:
|
|
role = msg.get("role", "?")
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
content = " ".join(
|
|
b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
|
|
)
|
|
# Truncate long messages
|
|
if len(content) > 500:
|
|
content = content[:500] + "..."
|
|
conv_lines.append(f"[{role}] {content}")
|
|
|
|
conversation = "\n".join(conv_lines)
|
|
|
|
prompt = SKILL_EXTRACT_PROMPT.format(rounds=round_count, tool_count=tool_count)
|
|
|
|
import time as _time
|
|
_t0 = _time.monotonic()
|
|
logger.debug(
|
|
"[skill-extract] calling LLM (endpoint=%s, ctx=%d msgs, timeout=30s)",
|
|
endpoint_url, len(recent),
|
|
)
|
|
response = await llm_call_async(
|
|
endpoint_url,
|
|
model,
|
|
[
|
|
{"role": "system", "content": prompt},
|
|
{"role": "user", "content": f"Conversation:\n{conversation}"},
|
|
],
|
|
headers=headers,
|
|
timeout=30,
|
|
reasoning_effort=effort_for_call(endpoint_url, model, owner, session),
|
|
)
|
|
logger.debug(
|
|
"[skill-extract] LLM returned in %.1fs (len=%d, head=%r)",
|
|
_time.monotonic() - _t0, len(response or ""), (response or "")[:80],
|
|
)
|
|
|
|
if not response or response.strip().lower() == "null":
|
|
logger.debug(
|
|
"[skill-extract] LLM declined (returned null/empty) — "
|
|
"session deemed not a reusable procedure"
|
|
)
|
|
return None
|
|
|
|
# Some models (MiniMax, Qwen-Thinker, DeepSeek-R1) emit their
|
|
# chain-of-thought BEFORE the JSON output even when asked for
|
|
# raw JSON. `strip_think(prose=True, prompt_echo=True)` removes
|
|
# <think>…</think> tags AND prose-style "Let me analyze this…"
|
|
# preambles. Without it, json.loads bombed on character 0 every
|
|
# time and the silent-bail looked like "extractor doesn't work".
|
|
try:
|
|
from src.text_helpers import strip_think as _strip_think
|
|
response = _strip_think(response, prose=True, prompt_echo=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Parse JSON. The object may be wrapped in code fences or surrounded by
|
|
# commentary (and may contain a stray/invalid brace fragment before
|
|
# the real object — including one that makes the response itself look
|
|
# like it starts with '{'), so use a tolerant extractor that tries the
|
|
# whole string first and then each '{' candidate left-to-right.
|
|
data = _extract_json_object(response)
|
|
if not data:
|
|
logger.debug("[skill-extract] no JSON object found in response, dropping")
|
|
return None
|
|
|
|
title = data.get("title", "").strip()
|
|
if not title:
|
|
logger.debug("[skill-extract] LLM returned object with no title, dropping")
|
|
return None
|
|
|
|
# Honour the model's own reliability/reusability estimate — low-
|
|
# confidence extractions are usually one-offs or shaky procedures.
|
|
try:
|
|
_conf = float(data.get("confidence", 0.7))
|
|
except (TypeError, ValueError):
|
|
_conf = 0.7
|
|
if _conf < MIN_CONFIDENCE:
|
|
logger.debug(
|
|
"[skill-extract] '%s' below confidence floor (%.2f < %.2f) — dropped",
|
|
title, _conf, MIN_CONFIDENCE,
|
|
)
|
|
return None
|
|
|
|
# Check for duplicate skills
|
|
existing = skills_manager.load(owner=owner)
|
|
if _has_duplicate_title(existing, title):
|
|
logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title)
|
|
return None
|
|
|
|
# Automatic approval happens only after the audit has passed. A new
|
|
# extraction begins as a draft so it cannot enter chat context early.
|
|
_initial_status = "draft"
|
|
|
|
entry = skills_manager.add_skill(
|
|
title=title,
|
|
problem=data.get("problem", ""),
|
|
solution=data.get("solution", ""),
|
|
steps=data.get("steps", []),
|
|
tags=data.get("tags", []),
|
|
source="learned",
|
|
confidence=data.get("confidence", 0.7),
|
|
session_id=getattr(session, "session_id", None),
|
|
owner=owner,
|
|
status=_initial_status,
|
|
)
|
|
try:
|
|
from src.event_bus import fire_event
|
|
fire_event("skill_added", owner)
|
|
except Exception:
|
|
logger.debug("skill_added event dispatch failed", exc_info=True)
|
|
logger.info("Auto-extracted skill: %s (id=%s)", title, entry["id"])
|
|
return entry
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.debug("[skill-extract] non-JSON LLM response, dropping: %s", e)
|
|
return None
|
|
except Exception as e:
|
|
# Real exceptions stay INFO+warning so they don't get lost when
|
|
# users only have default log level. `exc_info=True` ships the
|
|
# full traceback so timeouts vs auth vs import errors are
|
|
# distinguishable from outside.
|
|
logger.warning("[skill-extract] FAILED: %s", e, exc_info=True)
|
|
return None
|