Files
odysseus/services/memory/skill_extractor.py
T
Mark CockramandAlexandre Teixeira 0e2c08300e feat(settings): opt-in reasoning effort for utility model calls (#6572)
* 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>
2026-10-13 21:02:16 +01:00

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