Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class Config(BaseModel):
mcpServers: dict[str, MCPServerConfig] = {}
save_sessions: bool = True
session_dataset_repo: str = "smolagents/ml-intern-sessions"
# Where session logs are stored locally. None = resolve at runtime via
# agent.core.session.resolve_session_log_dir (XDG data dir, overridable by
# the ML_INTERN_SESSION_DIR env var, with a legacy ./session_logs fallback).
session_log_dir: str | None = None
# Per-user private dataset that mirrors each session in Claude Code JSONL
# format so the HF Agent Trace Viewer auto-renders it
# (https://huggingface.co/changelog/agent-trace-viewer). Created private
Expand Down
121 changes: 119 additions & 2 deletions agent/core/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@
with_prompt_cache_params,
with_prompt_caching,
)
from agent.core.session import DEFAULT_SESSION_LOG_DIR, Event, OpType, Session
from agent.core.session import (
Event,
OpType,
Session,
resolve_session_log_dir,
)
from agent.core.tools import ToolRouter
from agent.core.usage_thresholds import (
USAGE_THRESHOLD_TOOL_NAME,
Expand Down Expand Up @@ -1243,6 +1248,102 @@ async def _call_llm_non_streaming(
)


# Strong references to in-flight auto-title tasks. asyncio only holds a weak
# reference to a bare create_task result, so without this the task could be
# GC'd mid-await and the title silently dropped (mirrors telemetry.py's
# _heartbeat_tasks pattern).
_title_tasks: set[asyncio.Task] = set()


async def _generate_and_set_title(
session: "Session",
final_response: str | None,
origin_session_id: str | None = None,
origin_epoch: int | None = None,
) -> None:
"""Generate a conversation title and attach it to the session.

Runs as a fire-and-forget task after the first turn. Any failure is
swallowed so it can never break the turn that spawned it.

``origin_session_id`` / ``origin_epoch`` snapshot the conversation identity
at spawn time; if they're omitted they default to the session's current
values. After the (multi-second) title LLM call we bail unless the session
is still the same conversation — otherwise a ``/new`` or ``/resume`` issued
during the await would let us stamp this title onto a different one.
"""
try:
from agent.core.title import (
extract_first_user_text,
generate_conversation_title,
)

if origin_session_id is None:
origin_session_id = session.session_id
if origin_epoch is None:
origin_epoch = session._conversation_epoch

first_user_text = extract_first_user_text(session.context_manager.items)
if not first_user_text:
return
title = await generate_conversation_title(
session.config.model_name,
session.hf_token,
first_user_text,
final_response,
)
# Bail if the user renamed, or a /new or /resume rotated the
# conversation, while we were awaiting the title.
if (
not title
or session._title_user_set
or session.session_title
or session.session_id != origin_session_id
or session._conversation_epoch != origin_epoch
):
return
session.session_title = title
# Persist the title so even a single-turn session is titled on disk.
session.persist_title()
await session.send_event(
Event(
event_type="conversation_title",
data={"title": title, "session_id": session.session_id},
)
)
except Exception as e: # noqa: BLE001
logger.debug("Auto-title task failed: %s", e)


def _maybe_spawn_auto_title(session: "Session", final_response: Any) -> None:
"""Spawn the one-shot auto-title task if this is the first untitled turn.

Called from every turn-completion path (normal and the usage-threshold /
YOLO / abandon resume paths) so a first turn that paused for an approval
still gets titled. Snapshots the conversation identity and keeps a strong
reference to the task. Never raises — a title must never break a turn.
"""
try:
if (
session.turn_count != 0
or session.session_title
or session._title_user_set
):
return
task = asyncio.create_task(
_generate_and_set_title(
session,
final_response if isinstance(final_response, str) else None,
session.session_id,
session._conversation_epoch,
)
)
_title_tasks.add(task)
task.add_done_callback(_title_tasks.discard)
except Exception as e: # noqa: BLE001
logger.debug("Auto-title spawn skipped: %s", e)


class Handlers:
"""Handler functions for each operation type"""

Expand Down Expand Up @@ -1288,6 +1389,9 @@ async def _abandon_pending_approval(session: Session) -> None:
},
)
)
# First turn may complete here (paused for an approval, then
# the user continued); title it before turn_count increments.
_maybe_spawn_auto_title(session, final_response)
session.increment_turn()
await session.auto_save_if_needed()
return
Expand Down Expand Up @@ -1952,6 +2056,11 @@ async def _exec_tool(
)
)

# Auto-title the conversation once, after the very first completed
# turn, unless the user already named it via /rename. Fire-and-forget
# so a slow or failing title never delays the turn.
_maybe_spawn_auto_title(session, final_response)

# Increment turn counter and check for auto-save
session.increment_turn()
await session.auto_save_if_needed()
Expand Down Expand Up @@ -2094,6 +2203,10 @@ async def _exec_usage_threshold_approval(
},
)
)
# First turn may complete here (paused for an approval); title it
# before turn_count increments so it isn't left permanently
# untitled.
_maybe_spawn_auto_title(session, final_response)
session.increment_turn()
await session.auto_save_if_needed()
return
Expand Down Expand Up @@ -2225,6 +2338,10 @@ async def _exec_yolo_budget_approval(
},
)
)
# First turn may complete here (paused for an approval); title it
# before turn_count increments so it isn't left permanently
# untitled.
_maybe_spawn_auto_title(session, final_response)
session.increment_turn()
await session.auto_save_if_needed()
return
Expand Down Expand Up @@ -2670,7 +2787,7 @@ async def submission_loop(
# to publish to the user's HF dataset gets a fresh attempt on next run.
if config and config.save_sessions:
Session.retry_failed_uploads_detached(
directory=str(DEFAULT_SESSION_LOG_DIR),
directory=str(resolve_session_log_dir(config)),
repo_id=config.session_dataset_repo,
personal_repo_id=session._personal_trace_repo_id(),
)
Expand Down
Loading