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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/query/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,15 @@ async def _call_model_sync(
_t0 = time.monotonic()
tool_schemas = []
for tool in tools:
# Filter out internal/hidden tools (is_enabled=False) so they
# don't leak into the API tools[] alongside the advisor schema
# we append below. Some callers pass an unfiltered tool list
# from ``registry.list_tools()``; this guard keeps the API
# from receiving duplicate names. ``getattr`` with default
# True keeps test fakes that don't implement is_enabled working.
is_enabled_fn = getattr(tool, "is_enabled", None)
if callable(is_enabled_fn) and not is_enabled_fn():
continue
tool_schemas.append({
"name": tool.name,
"description": tool.prompt(),
Expand Down Expand Up @@ -1478,6 +1487,12 @@ async def query(
# tools ignore the field (the existing default factory was an
# empty list, so behavior is unchanged for them).
tool_use_context.messages = list(messages)
# Also snapshot the active provider via a dynamic attribute so
# the client-side advisor can reuse it (and its config) when
# the user is on a proxy that probably proxies the advisor
# model too. Set as a plain attribute (not a dataclass field)
# to avoid touching ToolContext's public surface.
setattr(tool_use_context, "_active_provider", params.provider)

tool_results = await _run_tools_partitioned(
tool_use_blocks,
Expand Down
87 changes: 84 additions & 3 deletions src/tool_system/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,15 @@ def run_agent_loop(
Returns:
AgentLoopResult with final text response, usage info, and turn count
"""
# Filter by is_enabled() so internal/hidden tools (e.g. the
# client-side AdvisorTool, which is registered but
# ``is_enabled=False`` to keep it out of the default schema) don't
# leak into the tools[] sent to the API. The advisor schema is
# appended per-turn below when advisor activation fires.
tool_schemas = []
for tool in tool_registry.list_tools():
if not tool.is_enabled():
continue
tool_schemas.append({
"name": tool.name,
"description": tool.prompt(),
Expand Down Expand Up @@ -347,12 +354,77 @@ def _check_cancel() -> None:
# Use OpenAI formatted messages for non-Anthropic
api_messages = openai_messages

call_kwargs: dict[str, Any] = {"tools": tool_schemas}
# Advisor activation — same decision tree as query.py's
# ``_call_model_sync``. We recompute per turn so that mid-session
# ``/advisor`` toggles take effect on the very next turn. The
# main path (REPL/TUI/headless) all routes through this function;
# without this block, the advisor would never actually fire in
# those paths even though the schema + instructions are correctly
# built. See src/utils/advisor.py:decide_advisor_mode.
from src.utils.advisor import (
ADVISOR_BETA_HEADER,
ADVISOR_MODE_CLIENT_SIDE,
ADVISOR_MODE_INACTIVE,
ADVISOR_MODE_SERVER_SIDE,
ADVISOR_TOOL_INSTRUCTIONS,
build_advisor_tool_schema,
build_client_advisor_tool_schema,
decide_advisor_mode,
)
advisor_mode = ADVISOR_MODE_INACTIVE
advisor_model_canonical: str | None = None
try:
from src.models.model import canonical_model_name
from src.settings.settings import get_settings
settings = get_settings()
configured = (getattr(settings, "advisor_model", "") or "").strip()
force_client = bool(getattr(settings, "advisor_client_mode", False))
main_loop_model = getattr(provider, "model", "") or ""
if configured:
candidate = canonical_model_name(configured)
advisor_mode = decide_advisor_mode(
provider,
main_loop_model,
candidate,
force_client_mode=force_client,
)
if advisor_mode != ADVISOR_MODE_INACTIVE:
advisor_model_canonical = candidate
except Exception:
advisor_mode = ADVISOR_MODE_INACTIVE
advisor_model_canonical = None

# Build a per-turn tool list (base + maybe advisor at the END
# so the cache_control marker on the last base tool stays in
# place — same discipline as _call_model_sync).
turn_tool_schemas = list(tool_schemas)
if advisor_mode == ADVISOR_MODE_SERVER_SIDE and advisor_model_canonical:
turn_tool_schemas.append(build_advisor_tool_schema(advisor_model_canonical))
elif advisor_mode == ADVISOR_MODE_CLIENT_SIDE:
turn_tool_schemas.append(build_client_advisor_tool_schema())

# Per-turn system prompt — append ADVISOR_TOOL_INSTRUCTIONS at
# the end (cache-friendly position) for both server and client
# modes; both rely on the same instruction text.
turn_system_prompt = effective_system_prompt
if advisor_mode != ADVISOR_MODE_INACTIVE:
if turn_system_prompt:
turn_system_prompt = f"{turn_system_prompt}\n\n{ADVISOR_TOOL_INSTRUCTIONS}"
else:
turn_system_prompt = ADVISOR_TOOL_INSTRUCTIONS

call_kwargs: dict[str, Any] = {"tools": turn_tool_schemas}
if _is_anthropic_provider(provider):
call_kwargs["system"] = effective_system_prompt
call_kwargs["system"] = turn_system_prompt
else:
if turn == 0:
api_messages = [{"role": "system", "content": effective_system_prompt}, *api_messages]
api_messages = [{"role": "system", "content": turn_system_prompt}, *api_messages]
if advisor_mode == ADVISOR_MODE_SERVER_SIDE:
# Anthropic beta header. SDK auto-converts ``betas`` into the
# ``anthropic-beta`` header. Server-side advisor only fires
# on 1P Anthropic providers; agent_loop's anthropic path
# accepts arbitrary kwargs forwarded to the SDK.
call_kwargs["betas"] = [ADVISOR_BETA_HEADER]
response, streamed_live_text = _call_provider_for_turn(
provider=provider,
api_messages=api_messages,
Expand Down Expand Up @@ -430,6 +502,15 @@ def _check_cancel() -> None:
num_turns=turn_count,
)

# Snapshot active conversation + provider onto the ToolContext
# so tools that need them (currently: the client-side advisor)
# can read them. ``messages`` is the live conversation up to
# and including the assistant message that just emitted these
# tool_use blocks. ``_active_provider`` lets the advisor reuse
# the main provider's class/config when it's a proxy.
tool_context.messages = list(conversation.messages)
setattr(tool_context, "_active_provider", provider)

# Call each tool
for tool_use in tool_uses:
_check_cancel()
Expand Down
9 changes: 9 additions & 0 deletions src/tool_system/tools/advisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,19 @@ def _advisor_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResul
abort = getattr(context, "abort_controller", None)
abort_signal = getattr(abort, "signal", None) if abort is not None else None

# ``_active_provider`` is set by ``_call_model_sync`` before the
# tool round (dynamic attr, not a dataclass field). Letting
# ``execute_client_advisor`` see it enables the "reuse main
# provider when it's a proxy" routing — without it, an advisor
# running on a litellm-backed openai-compat session would bypass
# the proxy and hit api.anthropic.com directly.
main_provider = getattr(context, "_active_provider", None)

ok, text = execute_client_advisor(
advisor_model,
forwarded,
abort_signal=abort_signal,
main_provider=main_provider,
)
return ToolResult(
name="advisor",
Expand Down
Loading