add mcp and online search

This commit is contained in:
Abdelrahman Abdallah
2026-04-05 02:35:49 +02:00
parent 3f31cee395
commit 783145fe6a
38 changed files with 8114 additions and 261 deletions
+299 -7
View File
@@ -7,9 +7,12 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from .account_runtime import AccountRuntime
from .agent_manager import AgentManager
from .agent_context import clear_context_caches
from .agent_context import render_context_report as render_agent_context_report
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .agent_prompting import (
@@ -42,7 +45,10 @@ from .agent_types import (
from .openai_compat import OpenAICompatClient, OpenAICompatError
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .tokenizer_runtime import describe_token_counter
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -75,6 +81,10 @@ class LocalCodingAgent:
plugin_runtime: PluginRuntime | None = None
hook_policy_runtime: HookPolicyRuntime | None = None
mcp_runtime: MCPRuntime | None = None
remote_runtime: RemoteRuntime | None = None
search_runtime: SearchRuntime | None = None
account_runtime: AccountRuntime | None = None
config_runtime: ConfigRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
@@ -104,6 +114,23 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.remote_runtime is None:
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.search_runtime is None:
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.account_runtime is None:
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.config_runtime is None:
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
@@ -120,12 +147,17 @@ class LocalCodingAgent:
self.client = OpenAICompatClient(self.model_config)
self.tool_context = build_tool_context(
self.runtime_config,
tool_registry=self.tool_registry,
extra_env=(
self.hook_policy_runtime.safe_env()
if self.hook_policy_runtime is not None
else None
),
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
mcp_runtime=self.mcp_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
@@ -919,6 +951,7 @@ class LocalCodingAgent:
'preflight_count': len(plugin_preflight_messages),
}
)
self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result)
history_entry = self._build_file_history_entry(
tool_call=tool_call,
tool_result=tool_result,
@@ -1270,9 +1303,9 @@ class LocalCodingAgent:
if current_total <= target_tokens and not reactive:
break
message = session.messages[index]
original_tokens = estimate_tokens(message.content)
original_tokens = estimate_tokens(message.content, self.model_config.model)
replacement = self._build_snipped_message_content(message)
replacement_tokens = estimate_tokens(replacement)
replacement_tokens = estimate_tokens(replacement, self.model_config.model)
if replacement_tokens >= original_tokens:
continue
session.tombstone_message(
@@ -2832,28 +2865,194 @@ class LocalCodingAgent:
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
return '\n'.join(['# Memory', '', claude_md])
def render_account_report(self, profile: str | None = None) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
if profile:
return self.account_runtime.render_profile(profile)
return '\n'.join(['# Account', '', self.account_runtime.render_summary()])
def render_search_report(
self,
query: str | None = None,
*,
provider: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return (
'# Search\n\nNo local search provider is available. '
'Add a .claw-search.json or .claude/search.json manifest, '
'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.'
)
if query:
try:
return self.search_runtime.render_search_results(
query,
provider_name=provider,
max_results=max_results,
domains=domains,
timeout_seconds=self.runtime_config.command_timeout_seconds,
)
except (KeyError, LookupError, OSError, ValueError) as exc:
return f'# Search\n\nSearch failed: {exc}'
if provider:
return self.search_runtime.render_provider(provider)
return '\n'.join(['# Search', '', self.search_runtime.render_summary()])
def render_search_providers_report(self, query: str | None = None) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search Providers\n\nNo local search providers discovered.'
return self.search_runtime.render_providers_index(query=query)
def render_search_activate_report(self, provider: str) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search\n\nNo local search provider is available.'
try:
report = self.search_runtime.activate_provider(provider)
except KeyError:
return f'# Search\n\nUnknown search provider: {provider}'
clear_context_caches()
self.tool_context = replace(
self.tool_context,
search_runtime=self.search_runtime,
)
return '\n'.join(['# Search', '', report.as_text()])
def render_account_profiles_report(self, query: str | None = None) -> str:
if self.account_runtime is None:
return '# Account Profiles\n\nNo local account runtime is available.'
return self.account_runtime.render_profiles_index(query=query)
def render_account_login_report(
self,
target: str,
*,
provider: str | None = None,
auth_mode: str | None = None,
) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode)
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_account_logout_report(self) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.logout(reason='slash_or_cli_logout')
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_config_report(self) -> str:
if self.config_runtime is None:
return '# Config\n\nNo local config runtime is available.'
return '\n'.join(['# Config', '', self.config_runtime.render_summary()])
def render_config_effective_report(self) -> str:
if self.config_runtime is None:
return '# Config Effective\n\nNo local config runtime is available.'
return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()])
def render_config_source_report(self, source: str) -> str:
if self.config_runtime is None:
return '# Config Source\n\nNo local config runtime is available.'
return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)])
def render_config_value_report(self, key_path: str, source: str | None = None) -> str:
if self.config_runtime is None:
return '# Config Value\n\nNo local config runtime is available.'
try:
rendered = self.config_runtime.render_value(key_path, source=source)
except KeyError as exc:
label = source if source is not None else key_path
return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}'
return '\n'.join(['# Config Value', '', rendered])
def render_mcp_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP\n\nNo local MCP manifests or resources discovered.'
return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.'
if query:
return self.mcp_runtime.render_resource_index(query=query)
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
def render_remote_report(self, target: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
if target:
report = self.remote_runtime.connect(target)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()])
def render_remote_mode_report(self, target: str, *, mode: str) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.connect(target, mode=mode)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_remote_profiles_report(self, query: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote Profiles\n\nNo local remote runtime is available.'
return self.remote_runtime.render_profiles_index(query=query)
def render_remote_disconnect_report(self) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.disconnect()
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_mcp_resources_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP Resources\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource_index(query=query)
def render_mcp_resource_report(self, uri: str) -> str:
if self.mcp_runtime is None:
return '# MCP Resource\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource(uri)
def render_mcp_tools_report(
self,
query: str | None = None,
*,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_tool_index(query=query, server_name=server)
def render_mcp_call_tool_report(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.'
try:
return self.mcp_runtime.render_tool_call(
tool_name,
arguments=arguments,
server_name=server,
)
except FileNotFoundError as exc:
return f'# MCP Tool Result\n\n{exc}'
def render_tasks_report(self, status: str | None = None) -> str:
if self.task_runtime is None:
return '# Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_tasks(status=status)
def render_next_tasks_report(self) -> str:
if self.task_runtime is None:
return '# Next Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_next_tasks()
def render_plan_report(self) -> str:
if self.plan_runtime is None:
return '# Plan\n\nNo local plan runtime is available.'
@@ -2891,10 +3090,12 @@ class LocalCodingAgent:
return '\n'.join(lines)
def render_status_report(self) -> str:
token_counter = describe_token_counter(self.model_config.model)
lines = [
'# Status',
'',
f'- Model: {self.model_config.model}',
f'- Token counter: {token_counter.backend} ({token_counter.source})',
f'- Registered tools: {len(self.tool_registry)}',
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
f'- Session ID: {self.active_session_id or "none"}',
@@ -2904,8 +3105,37 @@ class LocalCodingAgent:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
if self.mcp_runtime is not None and self.mcp_runtime.resources:
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime is not None:
if self.mcp_runtime.resources:
lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime.servers:
lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}')
if self.remote_runtime is not None and self.remote_runtime.has_remote_config():
lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}')
if self.remote_runtime.active_connection is not None:
connection = self.remote_runtime.active_connection
lines.append(
f'- Active remote: {connection.mode} -> {connection.target}'
)
if self.search_runtime is not None and self.search_runtime.has_search_runtime():
lines.append(f'- Search providers: {len(self.search_runtime.providers)}')
active_provider = self.search_runtime.current_provider()
if active_provider is not None:
lines.append(
f'- Active search provider: {active_provider.name} ({active_provider.provider})'
)
if self.account_runtime is not None and self.account_runtime.has_account_state():
lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}')
if self.account_runtime.active_session is not None:
session = self.account_runtime.active_session
lines.append(
f'- Active account: {session.provider} -> {session.identity}'
)
if self.config_runtime is not None and self.config_runtime.has_config():
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
lines.append(
f'- Effective config keys: {len(self.config_runtime.list_keys())}'
)
if self.plan_runtime is not None and self.plan_runtime.steps:
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
if self.task_runtime is not None and self.task_runtime.tasks:
@@ -2945,6 +3175,68 @@ class LocalCodingAgent:
)
self.resume_source_session_id = None
def _refresh_runtime_views_for_tool_result(
self,
tool_name: str,
tool_result: ToolExecutionResult,
) -> None:
if not tool_result.ok:
return
refresh_tool_names = {
'update_plan',
'plan_clear',
'task_create',
'task_update',
'task_start',
'task_complete',
'task_block',
'task_cancel',
'todo_write',
'search_activate_provider',
'remote_connect',
'remote_disconnect',
'account_login',
'account_logout',
'config_set',
}
if tool_name not in refresh_tool_names:
return
clear_context_caches()
additional_dirs = tuple(
str(path) for path in self.runtime_config.additional_working_directories
)
if tool_name.startswith('remote_'):
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('search_'):
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('account_'):
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name == 'config_set':
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('task_') or tool_name == 'todo_write':
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('plan_') or tool_name == 'update_plan':
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
self.tool_context = replace(
self.tool_context,
tool_registry=self.tool_registry,
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
if self.plugin_runtime is None:
return prompt