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
213 lines
9.4 KiB
Python
213 lines
9.4 KiB
Python
"""Subagent loop capability — consult the user's live local agent as a delegate.
|
|
|
|
Active whenever the user's selected knowledge base is a connected subagent
|
|
(resolved by :mod:`deeptutor.capabilities.subagent.binding`). As a
|
|
:class:`KnowledgeCapability` it owns the turn: the chat loop runs exclusively on
|
|
the single ``consult_subagent`` tool (plus the ``ask_user`` floor). The chat
|
|
model decides what to ask, asks the local Claude Code / Codex up to the consult
|
|
budget, watches its streamed run, and then answers the user in its own voice.
|
|
|
|
The connection (which backend, working dir), the per-backend config and the
|
|
turn-scoped budget/session state are injected into each tool call server-side;
|
|
the model never supplies them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from deeptutor.capabilities.protocol import KnowledgeCapability, PromptBlock
|
|
from deeptutor.capabilities.subagent.binding import connection_for_turn
|
|
from deeptutor.capabilities.subagent.tools import SUBAGENT_TOOL_NAMES
|
|
from deeptutor.core.context import UnifiedContext
|
|
|
|
# Headroom over the consult budget so the loop always has rounds left to write
|
|
# the final answer after the last consult. Read by the pipeline via
|
|
# ``context.metadata["_min_loop_rounds"]`` (a generic seam, like solve's
|
|
# ``solve_max_replans``) so a high budget is never clipped by the default round
|
|
# budget.
|
|
_FINISH_HEADROOM = 2
|
|
|
|
|
|
class SubagentCapability(KnowledgeCapability):
|
|
"""Turn-scoped integration for a connected local subagent."""
|
|
|
|
name = "subagent"
|
|
owned_tools = SUBAGENT_TOOL_NAMES
|
|
|
|
def is_active(self, context: UnifiedContext) -> bool:
|
|
return connection_for_turn(context) is not None
|
|
|
|
def system_block(
|
|
self,
|
|
context: UnifiedContext,
|
|
*,
|
|
language: str,
|
|
prompts: dict[str, Any],
|
|
) -> PromptBlock | None:
|
|
conn = connection_for_turn(context)
|
|
if conn is None:
|
|
return None
|
|
budget = _resolve_budget(context)
|
|
# Ensure the loop has room for ``budget`` consults plus the answer. This
|
|
# runs once during prompt assembly, before the loop reads its round
|
|
# budget — see ``_FINISH_HEADROOM``.
|
|
context.metadata["_min_loop_rounds"] = budget + _FINISH_HEADROOM
|
|
return PromptBlock(
|
|
"subagent", _system_text(language, conn["name"], budget, conn.get("kind", ""))
|
|
)
|
|
|
|
def augment_kwargs(
|
|
self,
|
|
tool_name: str,
|
|
kwargs: dict[str, Any],
|
|
context: UnifiedContext,
|
|
) -> dict[str, Any]:
|
|
if tool_name not in SUBAGENT_TOOL_NAMES:
|
|
return kwargs
|
|
conn = connection_for_turn(context)
|
|
if conn is None:
|
|
return kwargs
|
|
from deeptutor.services.subagent import load_subagent_settings
|
|
from deeptutor.services.subagent.sessions import get_session, session_key
|
|
|
|
settings = load_subagent_settings()
|
|
# Turn-scoped, mutable: persists across the loop's rounds via the shared
|
|
# context object — the consult counter and the backend session id that
|
|
# threads context across the model's successive questions.
|
|
state = context.metadata.setdefault(
|
|
"_subagent_state",
|
|
{"count": 0, "session_id": None, "name": conn["name"]},
|
|
)
|
|
# Persistent continuity: on the first consult of a turn, seed the session
|
|
# id from the cross-turn registry so we resume the SAME local agent
|
|
# session the user/DeepTutor built up earlier (and the sidebar shares).
|
|
chat_sid = str(getattr(context, "session_id", "") or "")
|
|
skey = session_key(chat_sid, conn["name"]) if chat_sid else ""
|
|
if skey and not state.get("_seeded"):
|
|
state["_seeded"] = True
|
|
if not state.get("session_id"):
|
|
state["session_id"] = get_session(skey)
|
|
config = _effective_config(settings.backend(conn["kind"]))
|
|
# Multimodal: forward the turn's image attachments only when the user
|
|
# opted this backend in (/settings → forward_images). The tool
|
|
# materializes them to files the CLI can ingest.
|
|
images = (
|
|
[a for a in (context.attachments or []) if getattr(a, "type", "") == "image"]
|
|
if getattr(config, "forward_images", False)
|
|
else []
|
|
)
|
|
updated = dict(kwargs)
|
|
updated["_subagent"] = {
|
|
"kind": conn["kind"],
|
|
"cwd": conn.get("cwd") or "",
|
|
"partner_id": conn.get("partner_id") or "",
|
|
"name": conn["name"],
|
|
"budget": _resolve_budget(context),
|
|
"config": config,
|
|
"state": state,
|
|
"images": images,
|
|
"session_key": skey,
|
|
}
|
|
return updated
|
|
|
|
def pre_loop_seed(self, context: UnifiedContext) -> str:
|
|
_ = context
|
|
return ""
|
|
|
|
|
|
# Default instruction injected (CC --append-system-prompt) so a consulted agent
|
|
# behaves like a delegate, not an interactive session, when the user hasn't set
|
|
# their own in /settings.
|
|
_DEFAULT_CONSULT_INSTRUCTION = (
|
|
"You are being consulted programmatically by DeepTutor on the user's behalf, "
|
|
"not in an interactive terminal. Answer the question directly, concisely, and "
|
|
"self-contained. Do not ask the user follow-up questions or wait for input; "
|
|
"if something is ambiguous, state your assumption and proceed."
|
|
)
|
|
|
|
|
|
def _effective_config(config):
|
|
"""Fill product defaults the user hasn't overridden in /settings.
|
|
|
|
Today: a default ``system_prompt`` so the consulted agent knows it's a
|
|
delegate (applied by backends that support it, e.g. Claude Code).
|
|
"""
|
|
if config.system_prompt.strip():
|
|
return config
|
|
from dataclasses import replace
|
|
|
|
return replace(config, system_prompt=_DEFAULT_CONSULT_INSTRUCTION)
|
|
|
|
|
|
def _resolve_budget(context: UnifiedContext) -> int:
|
|
"""Consult budget for this turn: a per-turn override from the chat composer
|
|
(``config.subagent_consult_budget``) if present, else the configured default.
|
|
"""
|
|
from deeptutor.services.subagent import load_subagent_settings
|
|
from deeptutor.services.subagent.config import CONSULT_BUDGET_MAX, CONSULT_BUDGET_MIN
|
|
|
|
overrides = context.config_overrides if isinstance(context.config_overrides, dict) else {}
|
|
raw = overrides.get("subagent_consult_budget")
|
|
if raw is not None:
|
|
try:
|
|
return max(CONSULT_BUDGET_MIN, min(CONSULT_BUDGET_MAX, int(raw)))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return load_subagent_settings().consult_budget
|
|
|
|
|
|
def _system_text(language: str, name: str, budget: int, kind: str = "") -> str:
|
|
from deeptutor.services.subagent import PARTNER_BACKEND_KIND
|
|
|
|
is_partner = kind == PARTNER_BACKEND_KIND
|
|
zh = str(language or "en").lower().startswith("zh")
|
|
if zh:
|
|
if is_partner:
|
|
framing = (
|
|
f"本轮你已连接到用户的伙伴「{name}」——一个有自己人格、知识库与技能的助手。你可以通过 "
|
|
f"`consult_subagent` 工具向它咨询,把它当作一位独立的同事来求助(例如借助它专属的知识库"
|
|
f"或视角来回答)。你们的往来会作为一个完整会话归档到该伙伴的历史里,它的回复过程会实时展示给用户。"
|
|
)
|
|
else:
|
|
framing = (
|
|
f"本轮你已连接到用户本机的外部智能体「{name}」。你可以通过 `consult_subagent` "
|
|
f"工具向它提问,把它当作一个能在用户机器上读写文件、运行命令的得力助手来委派任务"
|
|
f"(例如排查代码库、复现问题、运行脚本)。它的完整运行过程会实时展示给用户。"
|
|
)
|
|
return (
|
|
f"{framing}\n\n"
|
|
f"- 本轮最多可向它提问 {budget} 次;每次结果会告诉你还剩几次。它会在本轮内记住你"
|
|
f"之前的提问,所以可以层层追问。\n"
|
|
f"- 当你掌握了足够信息后,停止调用该工具,用你自己的口吻直接回答用户——"
|
|
f"不要假借它的身份或第一人称转述它的话。"
|
|
)
|
|
if is_partner:
|
|
framing = (
|
|
f"You are connected this turn to the user's partner “{name}” — a companion "
|
|
f"with its own persona, library and skills. Consult it with the "
|
|
f"`consult_subagent` tool as you would an independent colleague (e.g. for "
|
|
f"its dedicated knowledge or perspective). Your exchange is archived as one "
|
|
f"complete session in that partner's history, and its reply is shown to the "
|
|
f"user live."
|
|
)
|
|
else:
|
|
framing = (
|
|
f"You are connected this turn to the user's local external agent “{name}”. "
|
|
f"Consult it with the `consult_subagent` tool, delegating work it is better "
|
|
f"placed to do on the user's machine — inspecting a codebase, reproducing a "
|
|
f"bug, running commands. Its full run is shown to the user live."
|
|
)
|
|
return (
|
|
f"{framing}\n\n"
|
|
f"- You may consult it at most {budget} time(s) this turn; each result tells "
|
|
f"you how many remain. It remembers your earlier questions this turn, so you "
|
|
f"can drill down.\n"
|
|
f"- Once you have enough, stop calling the tool and answer the user directly "
|
|
f"in your own voice — never impersonate it or relay its words in the first "
|
|
f"person."
|
|
)
|
|
|
|
|
|
__all__ = ["SubagentCapability"]
|