项目文件夹

文件
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 行
52 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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",
]