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
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""Tests for the subagent capability: binding, activation, tool budget/streaming.
|
|
|
|
The capability is the connected-agent twin of Obsidian — selecting a
|
|
``type: subagent`` KB runs the turn exclusively on ``consult_subagent``. These
|
|
tests stub the KB metadata resolver and the backend, so nothing spawns a real
|
|
CLI; they verify the wiring (binding, exclusivity, injected spec) and the tool's
|
|
authoritative consult-budget + session continuity + event streaming.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from deeptutor.agents._shared.tool_composition import ToolMountFlags, compose_enabled_tools
|
|
from deeptutor.capabilities import any_exclusive_capability_active
|
|
from deeptutor.capabilities.subagent import (
|
|
SUBAGENT_TOOL_NAMES,
|
|
ConsultSubagentTool,
|
|
SubagentCapability,
|
|
connection_for_turn,
|
|
)
|
|
from deeptutor.capabilities.subagent import binding as subagent_binding
|
|
from deeptutor.core.context import UnifiedContext
|
|
from deeptutor.runtime.registry.tool_registry import get_tool_registry
|
|
from deeptutor.services.subagent.config import BackendConfig
|
|
from deeptutor.services.subagent.types import ConsultResult, SubagentEvent
|
|
|
|
|
|
def _bind(monkeypatch, *, kind: str = "claude_code", cwd: str = "", name: str = "myagent") -> None:
|
|
"""Make ``resolve_kb_metadata`` report ``name`` as a connected subagent."""
|
|
monkeypatch.setattr(
|
|
"deeptutor.multi_user.knowledge_access.resolve_kb_metadata",
|
|
lambda ref: (
|
|
{"name": ref, "type": "subagent", "agent_kind": kind, "cwd": cwd}
|
|
if ref == name
|
|
else {"name": ref, "type": None}
|
|
),
|
|
)
|
|
|
|
|
|
# ---- binding & activation ----------------------------------------------------
|
|
|
|
|
|
def test_inactive_without_subagent_kb(monkeypatch) -> None:
|
|
_bind(monkeypatch)
|
|
cap = SubagentCapability()
|
|
ctx = UnifiedContext(user_message="hi", knowledge_bases=["plain-kb"])
|
|
assert cap.is_active(ctx) is False
|
|
assert cap.system_block(ctx, language="en", prompts={}) is None
|
|
|
|
|
|
def test_active_injects_spec_and_min_rounds(monkeypatch) -> None:
|
|
_bind(monkeypatch, kind="codex", cwd="/tmp/proj")
|
|
cap = SubagentCapability()
|
|
ctx = UnifiedContext(user_message="hi", knowledge_bases=["myagent"])
|
|
assert cap.is_active(ctx) is True
|
|
assert tuple(cap.owned_tools) == SUBAGENT_TOOL_NAMES
|
|
|
|
block = cap.system_block(ctx, language="en", prompts={})
|
|
assert block is not None and "myagent" in block.content
|
|
# The loop budget floor is lifted so the full consult budget + a finish
|
|
# round always fit.
|
|
assert ctx.metadata.get("_min_loop_rounds", 0) >= 2
|
|
|
|
spec = cap.augment_kwargs("consult_subagent", {"question": "q"}, ctx)["_subagent"]
|
|
assert spec["kind"] == "codex"
|
|
assert spec["cwd"] == "/tmp/proj"
|
|
assert spec["budget"] >= 1
|
|
assert isinstance(spec["config"], BackendConfig)
|
|
assert spec["state"] == {"count": 0, "session_id": None, "name": "myagent"}
|
|
# Never injected for a non-owned tool.
|
|
assert "_subagent" not in cap.augment_kwargs("rag", {}, ctx)
|
|
|
|
|
|
def test_consult_budget_override_from_config(monkeypatch) -> None:
|
|
_bind(monkeypatch)
|
|
cap = SubagentCapability()
|
|
# Per-turn override from the composer (request config) wins over the default.
|
|
ctx = UnifiedContext(
|
|
user_message="hi",
|
|
knowledge_bases=["myagent"],
|
|
config_overrides={"subagent_consult_budget": 3},
|
|
)
|
|
assert (
|
|
cap.augment_kwargs("consult_subagent", {"question": "q"}, ctx)["_subagent"]["budget"] == 3
|
|
)
|
|
# Out-of-range values are clamped, not trusted.
|
|
ctx_hi = UnifiedContext(
|
|
user_message="hi",
|
|
knowledge_bases=["myagent"],
|
|
config_overrides={"subagent_consult_budget": 999},
|
|
)
|
|
assert (
|
|
cap.augment_kwargs("consult_subagent", {"question": "q"}, ctx_hi)["_subagent"]["budget"]
|
|
== 12
|
|
)
|
|
|
|
|
|
def test_binding_cached(monkeypatch) -> None:
|
|
calls = {"n": 0}
|
|
|
|
def fake(ref):
|
|
calls["n"] += 1
|
|
return {"name": ref, "type": "subagent", "agent_kind": "claude_code", "cwd": ""}
|
|
|
|
monkeypatch.setattr("deeptutor.multi_user.knowledge_access.resolve_kb_metadata", fake)
|
|
ctx = UnifiedContext(user_message="hi", knowledge_bases=["a"])
|
|
subagent_binding.connection_for_turn(ctx)
|
|
subagent_binding.connection_for_turn(ctx)
|
|
assert calls["n"] == 1 # second call hits the per-turn cache
|
|
|
|
|
|
# ---- exclusivity -------------------------------------------------------------
|
|
|
|
|
|
def test_exclusive_compose_drops_everything_but_consult_and_ask_user() -> None:
|
|
composed = compose_enabled_tools(
|
|
registry=get_tool_registry(),
|
|
requested_tools=["web_search", "rag"],
|
|
optional_whitelist=["web_search", "rag"],
|
|
mount_flags=ToolMountFlags(has_kb=True, has_code=True, has_memory=True),
|
|
capability_owned=["consult_subagent"],
|
|
exclusive=True,
|
|
)
|
|
assert set(composed) == {"consult_subagent", "ask_user"}
|
|
|
|
|
|
def test_registry_flags_subagent_turn_as_exclusive(monkeypatch) -> None:
|
|
_bind(monkeypatch)
|
|
subagent_turn = UnifiedContext(user_message="hi", knowledge_bases=["myagent"])
|
|
plain_turn = UnifiedContext(user_message="hi", knowledge_bases=["plain-kb"])
|
|
assert any_exclusive_capability_active(subagent_turn) is True
|
|
assert any_exclusive_capability_active(plain_turn) is False
|
|
|
|
|
|
# ---- consult tool ------------------------------------------------------------
|
|
|
|
|
|
class _FakeBackend:
|
|
kind = "claude_code"
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, str | None]] = []
|
|
self.last_images: list[str] | None = None
|
|
|
|
async def consult(
|
|
self, question, *, on_event, cwd, session_id, config, images=None, partner_id=None
|
|
):
|
|
self.calls.append((question, session_id))
|
|
self.last_images = images
|
|
await on_event(SubagentEvent(kind="tool", text="$ ls"))
|
|
await on_event(SubagentEvent(kind="result", text=f"answer:{question}"))
|
|
return ConsultResult(
|
|
final_text=f"answer:{question}",
|
|
session_id="sess-1",
|
|
success=True,
|
|
event_count=2,
|
|
)
|
|
|
|
|
|
def _spec(state: dict, *, budget: int = 2) -> dict:
|
|
return {
|
|
"kind": "claude_code",
|
|
"cwd": "",
|
|
"name": "myagent",
|
|
"budget": budget,
|
|
"config": BackendConfig(),
|
|
"state": state,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consult_streams_events_and_threads_session(monkeypatch) -> None:
|
|
backend = _FakeBackend()
|
|
monkeypatch.setattr("deeptutor.services.subagent.get_backend", lambda kind: backend)
|
|
tool = ConsultSubagentTool()
|
|
state: dict = {"count": 0, "session_id": None, "name": "myagent"}
|
|
streamed: list[tuple[str, str, str]] = []
|
|
|
|
async def sink(event_type, message, metadata=None):
|
|
streamed.append((event_type, message, (metadata or {}).get("subagent_channel", "")))
|
|
|
|
res1 = await tool.execute(question="Q1", _subagent=_spec(state), event_sink=sink)
|
|
assert res1.success is True
|
|
assert "answer:Q1" in res1.content
|
|
assert state["count"] == 1
|
|
assert state["session_id"] == "sess-1" # captured for continuity
|
|
# Every native event streamed out under the single subagent trace_kind.
|
|
assert all(etype == "subagent_event" for etype, _, _ in streamed)
|
|
channels = {chan for _, _, chan in streamed}
|
|
assert "tool" in channels and "result" in channels
|
|
|
|
# Second consult resumes the same backend session.
|
|
await tool.execute(question="Q2", _subagent=_spec(state), event_sink=sink)
|
|
assert backend.calls[1] == ("Q2", "sess-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consult_budget_is_authoritative(monkeypatch) -> None:
|
|
backend = _FakeBackend()
|
|
monkeypatch.setattr("deeptutor.services.subagent.get_backend", lambda kind: backend)
|
|
tool = ConsultSubagentTool()
|
|
state: dict = {"count": 0, "session_id": None, "name": "myagent"}
|
|
|
|
async def sink(*_a, **_k):
|
|
return None
|
|
|
|
await tool.execute(question="Q1", _subagent=_spec(state, budget=1), event_sink=sink)
|
|
# Budget of 1 is spent → the second consult is refused without driving the backend.
|
|
refused = await tool.execute(question="Q2", _subagent=_spec(state, budget=1), event_sink=sink)
|
|
assert refused.success is False
|
|
assert "budget" in refused.content.lower()
|
|
assert len(backend.calls) == 1 # backend never invoked the second time
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consult_without_spec_is_graceful() -> None:
|
|
res = await ConsultSubagentTool().execute(question="hi")
|
|
assert res.success is False and "no subagent" in res.content.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_id_persists_across_turns(monkeypatch, tmp_path) -> None:
|
|
# A backend session id captured in one turn is remembered (keyed by chat
|
|
# session + connection) and resumed by the next turn's augment_kwargs — so
|
|
# the local agent keeps context across DeepTutor's separate messages.
|
|
from deeptutor.services.subagent import sessions as sess
|
|
|
|
monkeypatch.setattr(sess, "_path", lambda: tmp_path / "subagent_sessions.json")
|
|
_bind(monkeypatch) # "myagent" → claude_code
|
|
backend = _FakeBackend()
|
|
monkeypatch.setattr("deeptutor.services.subagent.get_backend", lambda kind: backend)
|
|
|
|
cap = SubagentCapability()
|
|
tool = ConsultSubagentTool()
|
|
|
|
async def sink(*_a, **_k):
|
|
return None
|
|
|
|
# Turn 1: nothing remembered yet → consult creates "sess-1", which persists.
|
|
ctx1 = UnifiedContext(user_message="hi", knowledge_bases=["myagent"], session_id="chatA")
|
|
spec1 = cap.augment_kwargs("consult_subagent", {"question": "Q1"}, ctx1)["_subagent"]
|
|
assert spec1["state"]["session_id"] is None
|
|
await tool.execute(question="Q1", _subagent=spec1, event_sink=sink)
|
|
assert sess.get_session(sess.session_key("chatA", "myagent")) == "sess-1"
|
|
|
|
# Turn 2 (fresh context): augment_kwargs seeds the remembered session.
|
|
ctx2 = UnifiedContext(user_message="more", knowledge_bases=["myagent"], session_id="chatA")
|
|
spec2 = cap.augment_kwargs("consult_subagent", {"question": "Q2"}, ctx2)["_subagent"]
|
|
assert spec2["state"]["session_id"] == "sess-1"
|
|
|
|
# A different chat session does not inherit the agent session.
|
|
ctx3 = UnifiedContext(user_message="hi", knowledge_bases=["myagent"], session_id="chatB")
|
|
spec3 = cap.augment_kwargs("consult_subagent", {"question": "Q"}, ctx3)["_subagent"]
|
|
assert spec3["state"]["session_id"] is None
|