Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1302 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Chat capability assembly for the exploring-loop agent."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from deeptutor.agents._shared.tool_composition import (
ToolMountFlags,
compose_enabled_tools,
default_optional_tools,
user_has_memory,
user_has_notebooks,
)
from deeptutor.agents.chat.agent_loop import AgentLoop
from deeptutor.agents.chat.prompt_blocks import ChatPromptAssembler
from deeptutor.capabilities import (
LoopCapability,
active_loop_capabilities,
any_exclusive_capability_active,
)
from deeptutor.core.agentic import (
DispatchOutcome,
LLMClientConfig,
UsageTracker,
build_completion_kwargs,
build_openai_client,
can_use_native_tool_calling,
dispatch_tool_calls,
)
from deeptutor.core.agentic.tool_dispatch import MAX_PARALLEL_TOOL_CALLS
from deeptutor.core.context import UnifiedContext
from deeptutor.core.stream_bus import StreamBus
from deeptutor.core.trace import (
build_trace_metadata,
derive_trace_metadata,
merge_trace_metadata,
new_call_id,
)
from deeptutor.runtime.registry.deferred_tools import (
DeferredToolLoader,
render_deferred_tools_manifest,
)
from deeptutor.runtime.registry.tool_registry import get_tool_registry
from deeptutor.services.config import get_chat_params
from deeptutor.services.llm import (
get_llm_config,
get_token_limit_kwargs, # noqa: F401 (re-exported for tests)
prepare_multimodal_messages,
supports_tools, # noqa: F401 (re-exported for tests)
)
from deeptutor.services.llm.context_window import resolve_effective_context_window
from deeptutor.services.prompt import get_prompt_manager
from deeptutor.tools.builtin import PARTNER_BUILTIN_TOOL_NAMES
logger = logging.getLogger(__name__)
# Chat memory tools a partner turn replaces with the partner_* variants.
_PARTNER_SUPPRESSED_TOOLS: tuple[str, ...] = ("read_memory", "write_memory")
CHAT_EXCLUDED_TOOLS: set[str] = set()
CHAT_OPTIONAL_TOOLS = default_optional_tools(excluded=CHAT_EXCLUDED_TOOLS)
# Generation tools are user-toggleable + grant-gated, but only usable once an
# admin has configured an active model for the service. Drop them from a turn's
# tool list when unconfigured so the model never sees a tool that can only error.
_GENERATION_TOOL_SERVICES: dict[str, str] = {"imagegen": "imagegen", "videogen": "videogen"}
def _drop_unconfigured_generation_tools(tools: list[str]) -> list[str]:
present = [name for name in tools if name in _GENERATION_TOOL_SERVICES]
if not present:
return tools
try:
from deeptutor.services.config.model_catalog import get_model_catalog_service
service = get_model_catalog_service()
catalog = service.load()
configured = {
name
for name in present
if (service.get_active_model(catalog, _GENERATION_TOOL_SERVICES[name]) or {}).get(
"model"
)
}
except Exception:
logger.debug("generation-tool config probe failed; dropping them", exc_info=True)
configured = set()
return [name for name in tools if name not in _GENERATION_TOOL_SERVICES or name in configured]
KB_SEED_MAX_KBS = 3
KB_SEED_CHARS_PER_KB = 4000
# Exploring-loop budget: max LLM rounds in one turn's loop. A round without
# tool calls ends the loop early — that is the normal exit.
DEFAULT_MAX_ROUNDS = 8
CONTEXT_WINDOW_GUARD_RATIO = 0.9
_DispatchOutcome = DispatchOutcome
def _read_int(cfg: Any, *, key: str, default: int) -> int:
if isinstance(cfg, dict):
value = cfg.get(key, default)
else:
value = default
try:
return int(value)
except (TypeError, ValueError):
return default
def _normalise_user_reply(raw: Any) -> tuple[str, list[dict[str, str]] | None]:
if isinstance(raw, str):
return raw, None
if isinstance(raw, dict):
text = str(raw.get("text") or "")
answers_raw = raw.get("answers")
if isinstance(answers_raw, list) and answers_raw:
answers: list[dict[str, str]] = []
for entry in answers_raw:
if not isinstance(entry, dict):
continue
qid = str(entry.get("questionId") or entry.get("id") or "").strip()
if qid:
answers.append({"questionId": qid, "text": str(entry.get("text") or "")})
return text, answers or None
return text, None
return str(raw or ""), None
def _prompt_text(prompts: dict[str, Any], path: tuple[str, ...], default: str) -> str:
value: Any = prompts
for key in path:
if not isinstance(value, dict):
return default
value = value.get(key)
return value if isinstance(value, str) and value else default
def _format_user_reply_body(
text: str,
answers: list[dict[str, str]] | None,
ask_user_payload: dict[str, Any],
*,
prompts: dict[str, Any] | None = None,
) -> str:
prompt_map = prompts or {}
empty = _prompt_text(prompt_map, ("empty", "empty_reply"), "(empty reply)")
skipped = _prompt_text(prompt_map, ("empty", "skipped_reply"), "(skipped)")
question_fallback = _prompt_text(prompt_map, ("empty", "question_fallback"), "(question)")
user_answered = _prompt_text(prompt_map, ("empty", "user_answered"), "User answered:")
if answers:
prompts_by_id: dict[str, str] = {}
for q in ask_user_payload.get("questions") or []:
if isinstance(q, dict):
qid = str(q.get("id") or "")
prompts_by_id[qid] = str(q.get("prompt") or qid)
lines = [user_answered]
for entry in answers:
qid = entry.get("questionId", "")
prompt = prompts_by_id.get(qid) or qid or question_fallback
value = (entry.get("text") or "").strip() or skipped
lines.append(f"- {prompt}\n -> {value}")
return "\n".join(lines)
flat = (text or "").strip() or empty
return f"{user_answered} {flat}"
def _flatten_ask_user_summary(ask_user_payload: dict[str, Any]) -> str:
questions = ask_user_payload.get("questions") or []
if isinstance(questions, list) and questions:
prompts = [str(q.get("prompt") or "") for q in questions if isinstance(q, dict)]
prompts = [p for p in prompts if p]
if prompts:
return " | ".join(prompts)
return str(ask_user_payload.get("question") or "")
class AgenticChatPipeline:
"""Run chat as one exploring agent loop followed by a respond stage."""
def __init__(
self,
language: str = "en",
*,
max_rounds: int | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
self.language = "zh" if language.lower().startswith("zh") else "en"
self.llm_config = get_llm_config()
self.binding = getattr(self.llm_config, "binding", None) or "openai"
self.model = getattr(self.llm_config, "model", None)
self.api_key = getattr(self.llm_config, "api_key", None)
self.base_url = getattr(self.llm_config, "base_url", None)
self.api_version = getattr(self.llm_config, "api_version", None)
self.extra_headers = getattr(self.llm_config, "extra_headers", None) or {}
self.reasoning_effort = getattr(self.llm_config, "reasoning_effort", None)
self.registry = get_tool_registry()
self._usage = UsageTracker(model=self.model)
self._deferred_loader: DeferredToolLoader | None = None
self._deferred_pool: list[Any] = []
self._exec_enabled = False
try:
chat_cfg = get_chat_params()
except Exception as exc:
logger.warning("Failed to load chat params, using defaults: %s", exc)
chat_cfg = {}
try:
self._chat_temperature = float(chat_cfg.get("temperature", 0.2))
except (TypeError, ValueError):
self._chat_temperature = 0.2
self._max_rounds = _read_int(chat_cfg, key="max_rounds", default=DEFAULT_MAX_ROUNDS)
self._exploring_max_tokens = _read_int(
chat_cfg.get("exploring"), key="max_tokens", default=1600
)
self._respond_max_tokens = _read_int(
chat_cfg.get("responding"), key="max_tokens", default=8000
)
# Per-capability overrides (e.g. deep solve forwards its own round
# budget / temperature / answer-token cap, read from the solve
# settings). Chat itself passes none and keeps the chat_cfg values.
if max_rounds is not None:
self._max_rounds = max(1, int(max_rounds))
if temperature is not None:
self._chat_temperature = float(temperature)
if max_tokens is not None:
self._respond_max_tokens = max(256, int(max_tokens))
try:
self._prompts: dict[str, Any] = (
get_prompt_manager().load_prompts(
module_name="chat",
agent_name="agentic_chat",
language=self.language,
)
or {}
)
except Exception as exc:
logger.warning("Failed to load agentic_chat prompts: %s", exc)
self._prompts = {}
self._prompt_assembler = ChatPromptAssembler(
prompts=self._prompts,
language=self.language,
)
self._client_config = LLMClientConfig(
binding=self.binding,
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
api_version=self.api_version,
extra_headers=self.extra_headers or None,
reasoning_effort=self.reasoning_effort,
)
@property
def usage(self) -> UsageTracker:
return self._usage
@property
def max_rounds(self) -> int:
return max(1, self._max_rounds)
def effective_max_rounds(self, context: UnifiedContext) -> int:
"""Round budget for this turn, lifted to satisfy any capability minimum.
A capability that needs guaranteed loop headroom — the subagent
capability, which must allow its full consult budget plus a finishing
round — sets ``context.metadata["_min_loop_rounds"]``; the loop honours
the larger of that and the configured budget. A generic seam (like
solve's ``solve_max_replans``) so the loop stays capability-agnostic.
"""
try:
floor = int(context.metadata.get("_min_loop_rounds") or 0)
except (TypeError, ValueError):
floor = 0
return max(self.max_rounds, floor)
@property
def exploring_max_tokens(self) -> int:
return max(128, self._exploring_max_tokens)
@property
def respond_max_tokens(self) -> int:
return max(256, self._respond_max_tokens)
@property
def loop_max_tokens(self) -> int:
"""Single per-round token budget for the merged loop.
The loop has no separate exploring/respond split, so every round —
including the round that writes the final answer — uses one budget.
It must be large enough for a full answer; the responding budget is
that ceiling (tool-only rounds rarely approach it).
"""
return self.respond_max_tokens
async def run(self, context: UnifiedContext, stream: StreamBus) -> None:
await self._prepare_deferred_tools(context)
self._exec_enabled = await self._exec_allowed(context)
enabled_tools = self._compose_enabled_tools(context)
use_native_tools = bool(enabled_tools) and self._can_use_native_tool_calling()
tool_schemas = (
self._build_llm_tool_schemas(enabled_tools, context) if use_native_tools else None
)
if tool_schemas is not None and self._deferred_loader is not None:
tool_schemas.extend(self._deferred_loader.initial_schemas())
self._deferred_loader.bind_live_schemas(tool_schemas)
loop = AgentLoop(
pipeline=self,
context=context,
stream=stream,
client=self._build_openai_client(),
enabled_tools=enabled_tools if use_native_tools else [],
tool_schemas=tool_schemas,
)
await loop.run()
# ---- prompt assembly -------------------------------------------------
def _build_system_prompt(
self,
enabled_tools: list[str],
context: UnifiedContext,
*,
include_tool_manifest: bool = True,
) -> str:
return self._prompt_assembler.system_prompt(
context=context,
tool_manifest=self._tool_manifest(enabled_tools),
kb_note=self._kb_system_note(context),
deferred_tools_manifest=(
self._deferred_tools_manifest() if include_tool_manifest else ""
),
notebook_manifest=self._build_notebook_manifest(),
workspace_note=self._workspace_system_note(context),
capability_blocks=self._capability_system_blocks(context),
include_tool_manifest=include_tool_manifest,
)
def _build_loop_messages(
self,
*,
context: UnifiedContext,
enabled_tools: list[str],
kb_seed: str = "",
include_tool_manifest: bool = True,
) -> list[dict[str, Any]]:
"""Build the turn's ONE conversation.
The loop appends each round (assistant + ``role=tool`` results) to
this list, so the system prompt stays byte-stable for the whole turn
and the KB cache prefix is preserved. The KB seed rides inside the
trailing user message, not the system prompt.
"""
system_prompt = self._build_system_prompt(
enabled_tools,
context,
include_tool_manifest=include_tool_manifest,
)
user_content = self._prompt_assembler.user_message(
context=context,
kb_seed=kb_seed,
)
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
for item in context.conversation_history:
role = item.get("role")
content = item.get("content")
if role in {"user", "assistant"} and isinstance(content, (str, list)):
messages.append({"role": role, "content": content})
elif role == "system" and isinstance(content, str) and content.strip():
# ContextBuilder emits the compressed-history summary as a
# leading system message; deliver it right after the system
# prompt so compacted turns stay visible to the model.
header = _prompt_text(
self._prompts,
("notices", "conversation_summary_header"),
"[Conversation summary]",
)
messages.append({"role": "system", "content": f"{header}\n{content}"})
messages.append({"role": "user", "content": user_content})
return self._prepare_messages_with_attachments(messages, context)
def _finish_exhausted_instruction(self) -> str:
return self._prompt_assembler.finish_exhausted_instruction()
def _tool_manifest(self, enabled_tools: list[str]) -> str:
names = list(enabled_tools)
if self._deferred_loader is not None:
for name in sorted(self._deferred_loader.loaded_names):
if name not in names:
names.append(name)
try:
return self.registry.build_prompt_text(
names,
format="list_with_usage",
language=self.language,
)
except TypeError:
return self.registry.build_prompt_text(names)
except Exception:
logger.warning("failed to build tool prompt text", exc_info=True)
return ""
def _tool_result_snip_marker(self) -> str:
return self._t(
"notices.tool_result_snipped",
default=(
"[earlier tool result snipped to stay within context window; "
"call the same tool again if the content is still needed]"
),
)
def _prepare_messages_with_attachments(
self,
messages: list[dict[str, Any]],
context: UnifiedContext,
) -> list[dict[str, Any]]:
return prepare_multimodal_messages(
messages,
context.attachments,
binding=self.binding,
model=self.model,
).messages
# ---- deferred tools / tool composition ------------------------------
@staticmethod
def _is_partner_turn(context: UnifiedContext) -> bool:
"""Whether this turn runs under a partner's synthetic scope.
A partner turn executes as a synthetic non-admin user but acts as the
admin owner's extension. Authorization for these turns travels through
context metadata (the owner-scoped ``mcp_tools_filter`` / exec gate),
not the synthetic user's grant file — so callers must bypass real-user
grant resolution and defer to that metadata whitelist instead.
"""
return str((context.metadata or {}).get("source") or "") == "partner"
async def _prepare_deferred_tools(self, context: UnifiedContext) -> None:
try:
from deeptutor.services.mcp import get_mcp_manager, load_loaded_tools
await get_mcp_manager().ensure_started()
# Caller-scoped whitelist (e.g. a partner's configured MCP tools)
# intersected with the current user's grant. ``None`` means
# unrestricted; a set narrows the deferred tools. Real non-admin
# users fail closed when no MCP grant is present, while partner
# turns defer to their owner-scoped metadata whitelist as the
# authority (see ``_is_partner_turn``).
from deeptutor.multi_user.tool_access import allowed_mcp_tools, combine_whitelists
raw_filter = context.metadata.get("mcp_tools_filter")
caller_allowed = (
{str(name) for name in raw_filter} if isinstance(raw_filter, list) else None
)
user_allowed = None if self._is_partner_turn(context) else allowed_mcp_tools()
allowed: set[str] | None = combine_whitelists(caller_allowed, user_allowed)
pool = self.registry.deferred_tools()
if allowed is not None:
pool = [t for t in pool if t.get_definition().name in allowed]
self._deferred_pool = pool
if not pool:
self._deferred_loader = None
return
self._deferred_loader = DeferredToolLoader(
registry=self.registry,
session_id=context.session_id,
loaded=load_loaded_tools(context.session_id),
allowed=allowed,
)
except Exception:
logger.warning("deferred-tool preparation failed", exc_info=True)
self._deferred_loader = None
def _deferred_tools_manifest(self) -> str:
if self._deferred_loader is None:
return ""
return render_deferred_tools_manifest(
getattr(self, "_deferred_pool", None) or self.registry.deferred_tools(),
language=self.language,
)
async def _exec_allowed(self, context: UnifiedContext) -> bool:
try:
from deeptutor.services.sandbox import IsolationLevel, get_sandbox_service
# A partner turn runs as a synthetic non-admin user but IS the admin
# owner's extension (partners are anchored to the admin workspace), so
# exec follows the owner's authority — not the partner's "user" role.
# The owner still gates exec per-partner via the builtin-tool whitelist.
is_partner = self._is_partner_turn(context)
level = await get_sandbox_service().isolation_level()
if level is IsolationLevel.SYSTEM:
# Admin can switch exec off per user (grant v2). ``None``
# follows the policy: SYSTEM isolation serves everyone.
from deeptutor.multi_user.tool_access import exec_override
return exec_override() is not False
if level is IsolationLevel.APPLICATION:
if is_partner:
return True
try:
from deeptutor.multi_user.context import get_current_user
return bool(get_current_user().is_admin)
except Exception:
# Single-user local runtime: APPLICATION isolation is the
# same explicit opt-in posture TutorBot uses for local dev.
return True
return False
except Exception:
logger.warning("exec policy gate failed; disabling exec", exc_info=True)
return False
def _compose_enabled_tools(self, context: UnifiedContext) -> list[str]:
is_partner = self._is_partner_turn(context)
composed = compose_enabled_tools(
registry=self.registry,
requested_tools=context.enabled_tools,
optional_whitelist=CHAT_OPTIONAL_TOOLS,
mount_flags=ToolMountFlags(
has_kb=bool(self._selected_kbs(context)),
# read_source is owned by the explore_context pre-pass (it runs
# the investigation over attached sources), not the answer loop.
# Keep it off the answer surface even when sources are present.
has_sources=False,
has_memory=user_has_memory(),
has_notebooks=user_has_notebooks(),
has_skills=bool(context.skills_manifest),
has_deferred_tools=getattr(self, "_deferred_loader", None) is not None,
has_exec=getattr(self, "_exec_enabled", False),
has_code=getattr(self, "_exec_enabled", False),
),
capability_owned=self._capability_owned_tools(context),
exclusive=self._exclusive_capability_active(context),
builtin_whitelist=(
set(context.allowed_builtin_tools)
if context.allowed_builtin_tools is not None
else None
),
# Partners get the partner_* memory/history tools force-mounted and
# chat's read_memory/write_memory suppressed — the split-memory model
# (own workspace writable, owner's memory read-only) lives in those
# tools, not in chat's.
forced=PARTNER_BUILTIN_TOOL_NAMES if is_partner else (),
suppressed=_PARTNER_SUPPRESSED_TOOLS if is_partner else (),
)
return _drop_unconfigured_generation_tools(composed)
def _active_loop_capabilities(self, context: UnifiedContext) -> tuple[LoopCapability, ...]:
return active_loop_capabilities(context)
@staticmethod
def _exclusive_capability_active(context: UnifiedContext) -> bool:
"""True when a knowledge capability owns the turn (replaces the surface).
Suppresses rag scaffolding (KB seed / kb note) too — rag isn't mounted,
so seeding or advertising it would be wrong.
"""
return any_exclusive_capability_active(context)
def _capability_owned_tools(self, context: UnifiedContext) -> tuple[str, ...]:
"""The active capabilities' own tools — added on top of chat's full surface."""
names: list[str] = []
for cap in self._active_loop_capabilities(context):
names.extend(cap.owned_tools)
return tuple(names)
def _capability_system_blocks(self, context: UnifiedContext):
blocks = []
for cap in self._active_loop_capabilities(context):
block = cap.system_block(
context,
language=self.language,
prompts=self._prompts,
)
if block is not None:
blocks.append(block)
return blocks
def _capability_pre_loop_seed(self, context: UnifiedContext) -> str:
seeds = [
seed.strip()
for cap in self._active_loop_capabilities(context)
if (seed := cap.pre_loop_seed(context))
]
return "\n\n".join(seed for seed in seeds if seed)
async def _capability_pre_loop_briefings(
self,
context: UnifiedContext,
stream: StreamBus,
) -> str:
"""Run each active capability's optional async ``pre_loop`` hook and
join their returned blocks into one seed fragment.
The hook is optional (read via ``getattr`` so plain capabilities are
unaffected) and runs once before the answer loop's first LLM call —
see the ``pre_loop`` note on :class:`LoopCapability`. Failures are
swallowed: a pre-pass is best-effort grounding and must never sink the
turn.
"""
blocks: list[str] = []
for cap in self._active_loop_capabilities(context):
hook = getattr(cap, "pre_loop", None)
if not callable(hook):
continue
try:
block = await hook(context, stream, usage=self._usage)
except Exception:
logger.warning(
"pre_loop hook failed for capability %s",
getattr(cap, "name", "?"),
exc_info=True,
)
continue
content = (getattr(block, "content", "") or "").strip()
if content:
blocks.append(content)
return "\n\n".join(blocks)
def _build_llm_tool_schemas(
self,
enabled_tools: list[str],
context: UnifiedContext,
) -> list[dict[str, Any]]:
schemas = self.registry.build_openai_schemas(enabled_tools)
kb_choices = self._selected_kbs(context)
notebook_choices = self._notebook_choices()
for schema in schemas:
function = schema.get("function") if isinstance(schema, dict) else None
if not isinstance(function, dict):
continue
parameters = function.get("parameters")
if not isinstance(parameters, dict):
continue
properties = parameters.get("properties") or {}
if function.get("name") == "rag" and isinstance(properties, dict):
if isinstance(properties.get("query"), dict):
properties["query"].setdefault("minLength", 1)
if isinstance(properties.get("kb_name"), dict):
properties["kb_name"]["enum"] = kb_choices
if function.get("name") == "geogebra_analysis" and isinstance(properties, dict):
properties.pop("image_base64", None)
required = parameters.get("required")
if isinstance(required, list):
parameters["required"] = [n for n in required if n != "image_base64"]
if (
function.get("name") in {"list_notebook", "write_note"}
and isinstance(properties, dict)
and notebook_choices
and isinstance(properties.get("notebook_id"), dict)
):
nb_schema = properties["notebook_id"]
nb_schema["enum"] = [choice["id"] for choice in notebook_choices]
rendered = "; ".join(f"{c['id']} = {c['name']}" for c in notebook_choices)
nb_schema["description"] = (
f"{nb_schema.get('description', '').rstrip(' .')}. Available: {rendered}."
)
parameters["additionalProperties"] = False
return schemas
# ---- notebook / context helpers -------------------------------------
def _build_notebook_manifest(self) -> str:
choices = self._notebook_choices_full()
if not choices:
return ""
capped = choices[:30]
lines = ["[用户的笔记本列表]" if self.language == "zh" else "[User's notebooks]"]
for entry in capped:
nid = entry.get("id", "")
name = entry.get("name", nid)
count = entry.get("record_count", 0)
lines.append(f"- `{nid}` - {name} ({count} records)")
if len(choices) > len(capped):
lines.append(
f"... (+{len(choices) - len(capped)} more; call `list_notebook` to see the rest)"
)
return "\n".join(lines)
@staticmethod
def _notebook_choices_full() -> list[dict[str, Any]]:
try:
from deeptutor.services.notebook import get_notebook_manager
notebooks = get_notebook_manager().list_notebooks() or []
except Exception:
return []
rows: list[dict[str, Any]] = []
for nb in notebooks:
nid = str(nb.get("id") or "").strip()
if not nid:
continue
name = str(nb.get("name") or nb.get("title") or nid).strip() or nid
try:
count = int(nb.get("record_count") or 0)
except (TypeError, ValueError):
count = 0
rows.append({"id": nid, "name": name, "record_count": count})
return rows
@staticmethod
def _notebook_choices() -> list[dict[str, str]]:
return [
{"id": str(row["id"]), "name": str(row["name"])}
for row in AgenticChatPipeline._notebook_choices_full()
]
# ---- tool execution --------------------------------------------------
async def _execute_tool_call(
self,
tool_name: str,
tool_args: dict[str, Any],
*,
stream: StreamBus | None = None,
retrieve_meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
from deeptutor.core.agentic import execute_tool_call
stream = stream or StreamBus()
return await execute_tool_call(
registry=self.registry,
tool_name=tool_name,
tool_args=tool_args,
stream=stream,
source="chat",
stage="responding",
retrieve_meta=retrieve_meta,
empty_tool_result_message=self._t("notices.empty_tool_result"),
start_retrieval_message=self._t(
"notices.start_retrieval", default="Starting retrieval"
),
retrieve_label=self._t("labels.retrieve", default="Retrieve"),
unknown_error_message_factory=lambda tn: self._t(
"notices.tool_unknown_error",
tool=tn,
default=f"An unknown error occurred while executing {tn}.",
),
)
async def _dispatch_tool_calls(
self,
*,
tool_calls: list[dict[str, Any]],
context: UnifiedContext,
stream: StreamBus,
iteration_index: int,
stage: str = "exploring",
) -> DispatchOutcome:
too_many = None
if len(tool_calls) > MAX_PARALLEL_TOOL_CALLS:
too_many = self._t(
"notices.too_many_tool_calls",
requested=len(tool_calls),
limit=MAX_PARALLEL_TOOL_CALLS,
)
return await dispatch_tool_calls(
tool_calls=tool_calls,
context=context,
stream=stream,
source="chat",
stage=stage,
iteration_index=iteration_index,
registry=self.registry,
kwarg_augmenter=self._augment_tool_kwargs,
retrieve_meta_factory=lambda meta, tn, ta: self._retrieve_trace_metadata(
meta, context=context, tool_name=tn, tool_args=ta
),
tool_call_label=self._t("labels.tool_call", default="Tool call"),
retrieve_label=self._t("labels.retrieve", default="Retrieve"),
empty_tool_result_message=self._t("notices.empty_tool_result"),
start_retrieval_message=self._t(
"notices.start_retrieval", default="Starting retrieval"
),
too_many_tool_calls_message=too_many,
unknown_error_message_factory=lambda tn: self._t(
"notices.tool_unknown_error",
tool=tn,
default=f"An unknown error occurred while executing {tn}.",
),
trace_id_prefix="chat-loop",
)
async def _await_user_reply_and_resolve(
self,
*,
context: UnifiedContext,
stream: StreamBus,
dispatch: DispatchOutcome,
) -> bool:
ask_user = (dispatch.pause_payload or {}).get("ask_user") or {}
waiter = context.metadata.get("wait_for_user_reply")
if not callable(waiter):
await self._emit_terminator_final_response(
stream,
{
"tool_name": (dispatch.pause_payload or {}).get("tool_name", "ask_user"),
"content": _flatten_ask_user_summary(ask_user),
"metadata": {"ask_user": ask_user},
},
)
return False
raw_reply = await waiter()
if raw_reply is None:
return False
reply_text, answers = _normalise_user_reply(raw_reply)
body_text = _format_user_reply_body(
reply_text,
answers,
ask_user,
prompts=self._prompts,
)
continue_directive = self._t(
"notices.ask_user_resolved_directive",
default=(
"[ask_user resolved. Continue the user's original request using these answers. "
"Do not stop with an acknowledgement.]"
),
)
directive = f"{body_text}\n\n{continue_directive}"
for tm in dispatch.tool_messages:
if tm.get("tool_call_id") == dispatch.pause_tool_call_id:
tm["content"] = directive
break
meta: dict[str, Any] = {
"trace_kind": "user_reply",
"ask_user_resolved": True,
"ask_user_tool_call_id": dispatch.pause_tool_call_id,
"reply_preview": (reply_text or "")[:200],
}
if answers:
meta["answers"] = list(answers)
await stream.progress("", source="chat", stage="responding", metadata=meta)
return True
def _augment_tool_kwargs(
self,
tool_name: str,
args: dict[str, Any],
context: UnifiedContext,
) -> dict[str, Any]:
from deeptutor.services.path_service import get_path_service
kwargs = dict(args)
turn_id = str(context.metadata.get("turn_id", "") or "").strip()
workspace_key = self._workspace_key(context)
task_dir = (
get_path_service().get_task_workspace("chat", workspace_key) if workspace_key else None
)
exec_dir = task_dir / "exec" if task_dir is not None else None
if tool_name == "rag":
kwargs.setdefault("mode", "hybrid")
elif tool_name == "load_tools":
kwargs["_tool_loader"] = self._deferred_loader
elif tool_name == "exec":
from deeptutor.services.sandbox import Mount
kwargs["_sandbox_user_id"] = self._current_user_id()
if exec_dir is not None:
exec_dir.mkdir(parents=True, exist_ok=True)
kwargs["_sandbox_workdir"] = str(exec_dir)
kwargs["_sandbox_mounts"] = (
Mount(host_path=str(exec_dir), sandbox_path=str(exec_dir), read_only=False),
)
elif tool_name == "code_execution":
from deeptutor.services.sandbox import Mount
kwargs["_sandbox_user_id"] = self._current_user_id()
code_dir = task_dir / "code_runs" if task_dir is not None else None
if code_dir is not None:
code_dir.mkdir(parents=True, exist_ok=True)
kwargs["_sandbox_workdir"] = str(code_dir)
kwargs["_sandbox_mounts"] = (
Mount(host_path=str(code_dir), sandbox_path=str(code_dir), read_only=False),
)
elif tool_name in ("imagegen", "videogen"):
# Generated media lands in the turn's public workspace so it
# surfaces as a download card via /api/outputs (same convention as
# exec/code_execution artifacts).
media_dir = task_dir / "media" if task_dir is not None else None
if media_dir is not None:
media_dir.mkdir(parents=True, exist_ok=True)
kwargs["_workspace_dir"] = str(media_dir)
elif tool_name == "cron":
# Owner routing is supplied server-side — the model never picks
# where a scheduled task's output lands.
meta = context.metadata or {}
cron_job_id = str(meta.get("cron_job_id") or meta.get("_cron_job_id") or "")
kwargs["_cron_in_context"] = bool(
cron_job_id or str(meta.get("source") or "") == "cron"
)
if self._is_partner_turn(context):
channel_meta = meta.get("channel_metadata")
kwargs["_cron_owner"] = {
"kind": "partner",
"partner_id": str(meta.get("partner_id") or ""),
"channel": str(meta.get("channel") or ""),
"chat_id": str(meta.get("chat_id") or ""),
"session_key": str(meta.get("session_key") or ""),
"channel_meta": dict(channel_meta) if isinstance(channel_meta, dict) else {},
"language": context.language or "en",
}
else:
from deeptutor.multi_user.context import get_current_user
user = get_current_user()
kwargs["_cron_owner"] = {
"kind": "chat",
"user_id": user.id,
"is_admin": user.is_admin,
"session_id": context.session_id,
"language": context.language or "en",
}
elif tool_name in {"reason", "brainstorm"}:
kwargs.setdefault("context", context.user_message)
elif tool_name == "paper_search":
kwargs.setdefault("max_results", 3)
kwargs.setdefault("years_limit", 3)
kwargs.setdefault("sort_by", "relevance")
elif tool_name == "web_search":
kwargs.setdefault("query", context.user_message)
if task_dir is not None:
kwargs.setdefault("output_dir", str(task_dir / "web_search"))
elif tool_name == "write_note":
kwargs["conversation_history"] = list(context.conversation_history or [])
kwargs["current_user_message"] = context.user_message or ""
elif tool_name == "geogebra_analysis":
first_image = next(
(
att
for att in (context.attachments or [])
if getattr(att, "type", "") == "image" and getattr(att, "base64", "")
),
None,
)
if first_image is not None:
raw_b64 = first_image.base64
if raw_b64.startswith("data:"):
kwargs["image_base64"] = raw_b64
else:
mime = getattr(first_image, "mime_type", "") or "image/png"
kwargs["image_base64"] = f"data:{mime};base64,{raw_b64}"
kwargs["language"] = context.language or "zh"
for cap in self._active_loop_capabilities(context):
kwargs = cap.augment_kwargs(tool_name, kwargs, context)
return kwargs
def _retrieve_trace_metadata(
self,
tool_meta: dict[str, Any],
*,
context: UnifiedContext,
tool_name: str,
tool_args: dict[str, Any],
) -> dict[str, Any] | None:
_ = context
if tool_name == "rag":
return derive_trace_metadata(
tool_meta,
label=self._t("labels.retrieve", default="Retrieve"),
call_kind="rag_retrieval",
trace_role="retrieve",
trace_group="retrieve",
query=str(tool_args.get("query", "") or ""),
)
# imagegen/videogen are long-running: wiring retrieve_meta gives them an
# event_sink so their progress (esp. videogen's poll loop) streams to the
# client, which resets the chat idle-timeout watchdog mid-render.
if tool_name in ("imagegen", "videogen"):
return derive_trace_metadata(
tool_meta,
label=self._t("labels.tool_call", default="Tool call"),
call_kind="media_generation",
query=str(tool_args.get("prompt", "") or ""),
)
# consult_subagent drives a live local agent that runs for as long as it
# needs: wiring retrieve_meta gives it an event_sink so every native
# output/log streams to the sidebar in real time (and keeps the
# idle-timeout watchdog fed during a long agent run).
if tool_name == "consult_subagent":
return derive_trace_metadata(
tool_meta,
label=self._t("labels.consult_subagent", default="Consult agent"),
call_kind="subagent_consult",
query=str(tool_args.get("question", "") or ""),
)
return None
# ---- KB seed ---------------------------------------------------------
async def _retrieve_kb_seed_block(
self,
context: UnifiedContext,
stream: StreamBus,
) -> str:
if self._exclusive_capability_active(context):
return ""
kbs = self._selected_kbs(context)
query = (context.user_message or "").strip()
if not kbs or not query:
return ""
if len(kbs) > KB_SEED_MAX_KBS:
kbs = kbs[:KB_SEED_MAX_KBS]
results = await asyncio.gather(*(self._seed_search_one_kb(kb, query, stream) for kb in kbs))
sections: list[str] = []
sources: list[dict[str, Any]] = []
for kb, result in zip(kbs, results, strict=False):
if result is None:
continue
text, kb_sources = result
sections.append(f"## {kb}\n{text}")
sources.extend(kb_sources)
if not sections:
return ""
if sources:
await stream.sources(
sources, source="chat", stage="responding", metadata={"trace_kind": "sources"}
)
header = self._t(
"knowledge_base_seed.header",
default=(
"[Knowledge Base Context]\n"
"Passages retrieved from attached knowledge bases for the current question."
),
)
return header + "\n\n" + "\n\n".join(sections)
async def _seed_search_one_kb(
self,
kb_name: str,
query: str,
stream: StreamBus,
) -> tuple[str, list[dict[str, Any]]] | None:
call_id = new_call_id("chat-kb-seed")
retrieve_meta = build_trace_metadata(
call_id=call_id,
phase="responding",
label=self._t("labels.retrieve", default="Retrieve"),
call_kind="rag_retrieval",
trace_id=call_id,
trace_role="retrieve",
trace_group="retrieve",
query=query,
)
result = await self._execute_tool_call(
"rag",
{"query": query, "kb_name": kb_name, "mode": "hybrid"},
stream=stream,
retrieve_meta=retrieve_meta,
)
if not result.get("success"):
return None
metadata = result.get("metadata") or {}
if metadata.get("error_type") or metadata.get("needs_reindex"):
return None
text = str(metadata.get("content") or metadata.get("answer") or "").strip()
if not text:
return None
if len(text) > KB_SEED_CHARS_PER_KB:
text = text[:KB_SEED_CHARS_PER_KB].rstrip() + "\n...[truncated]"
return text, list(result.get("sources") or [])
# ---- emissions / context guard --------------------------------------
async def _emit_final_text(
self,
stream: StreamBus,
text: str,
final_meta: dict[str, Any],
) -> None:
if not text:
return
await stream.content(
text,
source="chat",
stage="responding",
metadata=merge_trace_metadata(final_meta, {"trace_kind": "llm_output"}),
)
async def _emit_protocol_fallback_final_response(
self,
stream: StreamBus,
content: str,
) -> None:
final_meta = build_trace_metadata(
call_id=new_call_id("chat-final-response"),
phase="responding",
label=self._t("labels.final_response", default="Final response"),
call_kind="llm_final_response",
trace_id="chat-final-response",
trace_role="response",
trace_group="stage",
fallback=True,
)
await self._emit_final_text(stream, content, final_meta)
async def _emit_terminator_final_response(
self,
stream: StreamBus,
payload: dict[str, Any] | None,
) -> None:
if not payload:
return
content = str(payload.get("content") or "").strip()
if not content:
return
final_meta = build_trace_metadata(
call_id=new_call_id("chat-final-response"),
phase="responding",
label=self._t("labels.final_response", default="Final response"),
call_kind="llm_final_response",
trace_id="chat-final-response",
trace_role="response",
trace_group="stage",
terminator_tool=str(payload.get("tool_name") or ""),
)
merged: dict[str, Any] = {"trace_kind": "llm_output"}
tool_metadata = payload.get("metadata") or {}
if isinstance(tool_metadata, dict) and tool_metadata:
merged["tool_metadata"] = dict(tool_metadata)
await stream.content(
content,
source="chat",
stage="responding",
metadata=merge_trace_metadata(final_meta, merged),
)
async def _guard_context_window(
self,
messages: list[dict[str, Any]],
stream: StreamBus,
) -> None:
try:
window = resolve_effective_context_window(
context_window=getattr(self.llm_config, "context_window", None),
model=str(self.model or ""),
max_tokens=getattr(self.llm_config, "max_tokens", None),
)
except Exception:
return
if not window or window <= 0:
return
budget = int(window * CONTEXT_WINDOW_GUARD_RATIO)
if self._estimate_messages_tokens(messages) <= budget:
return
snipped = False
for msg in messages:
if msg.get("role") != "tool":
continue
marker = self._tool_result_snip_marker()
if msg.get("content") == marker:
continue
msg["content"] = marker
snipped = True
if self._estimate_messages_tokens(messages) <= budget:
break
if snipped:
await stream.progress(
self._t("notices.context_window_guard"),
source="chat",
stage="responding",
metadata={"trace_kind": "warning"},
)
@staticmethod
def _estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
from deeptutor.services.session.context_builder import count_tokens
total = 0
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
total += count_tokens(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
total += count_tokens(str(part.get("text") or ""))
return total
# ---- LLM client ------------------------------------------------------
def _build_openai_client(self):
return build_openai_client(self._client_config)
def _completion_kwargs(self, max_tokens: int) -> dict[str, Any]:
return build_completion_kwargs(
temperature=self._chat_temperature,
model=self.model,
max_tokens=max_tokens,
binding=self.binding,
reasoning_effort=self.reasoning_effort,
)
def _can_use_native_tool_calling(self) -> bool:
return can_use_native_tool_calling(binding=self.binding, model=self.model)
# ---- small helpers ---------------------------------------------------
@staticmethod
def _current_user_id() -> str:
try:
from deeptutor.multi_user.context import get_current_user
return str(get_current_user().id or "anonymous")
except Exception:
return "anonymous"
@staticmethod
def _selected_kbs(context: UnifiedContext) -> list[str]:
return [str(kb).strip() for kb in context.knowledge_bases if str(kb).strip()]
@staticmethod
def _workspace_key(context: UnifiedContext) -> str:
raw = str(
context.metadata.get("turn_id")
or context.session_id
or context.metadata.get("message_id")
or "direct"
)
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in raw)
return cleaned.strip("_") or "direct"
def _kb_system_note(self, context: UnifiedContext) -> str:
if self._exclusive_capability_active(context):
return ""
kbs = self._selected_kbs(context)
if not kbs:
return ""
joined = ", ".join(kbs)
if self.language == "zh":
return f"用户已挂载知识库:{joined}。调用 rag 时,kb_name 必须从其中选一个。"
return f"Attached knowledge bases: {joined}. When calling rag, kb_name must be one of these names."
def _workspace_system_note(self, context: UnifiedContext) -> str:
if not getattr(self, "_exec_enabled", False):
return ""
try:
from deeptutor.services.path_service import get_path_service
exec_dir = (
get_path_service().get_task_workspace(
"chat",
self._workspace_key(context),
)
/ "exec"
)
except Exception:
return ""
if self.language == "zh":
return (
"[本轮工作区]\n"
f"脚本和临时文件应写入:{exec_dir}\n"
"相对路径会解析到这个目录。需要创建 PDF、图片、表格或其他下载文件时,"
"直接通过 exec 写入并运行脚本(如 heredocpython - <<'PY' … PY"
"或 cat > gen.py <<'EOF' … EOF 后再运行)。生成的文件会自动以可下载"
"卡片呈现给用户——在回答里描述你做了什么即可,不要粘贴原始 URL。"
)
return (
"[Turn workspace]\n"
f"Scripts and temporary files should be written under: {exec_dir}\n"
"Relative paths resolve to this directory. When creating PDFs, images, "
"spreadsheets, or other downloadable files, write and run scripts directly "
"through exec (e.g. a heredoc: python - <<'PY' … PY, or cat > gen.py <<'EOF' "
"… EOF then run it). Generated files are shown to the user automatically as "
"downloadable cards — describe what you made, do not paste raw URLs."
)
def _t(self, key: str, default: str = "", **kwargs: Any) -> str:
value: Any = self._prompts
for part in key.split("."):
if not isinstance(value, dict) or part not in value:
value = default
break
value = value[part]
if not isinstance(value, str):
value = default
if kwargs:
try:
return value.format(**kwargs)
except (KeyError, IndexError, ValueError):
return value
return value
__all__ = [
"AgenticChatPipeline",
"CHAT_OPTIONAL_TOOLS",
"KB_SEED_CHARS_PER_KB",
"KB_SEED_MAX_KBS",
"_DispatchOutcome",
"_read_int",
]