Merge pull request #61 from HKUDS/pr-60

Pr 60
This commit is contained in:
Dennis-yxchen 2026-04-06 20:14:44 +08:00 • committed by GitHub
commit b0021b46bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 87 additions and 20 deletions

View file

@ -322,7 +322,13 @@ async def refresh_mcp_cache(config_path: Optional[str] = None):
def _load_config(args) -> OpenSpaceConfig:
"""Load configuration"""
import os
from openspace.host_detection import build_llm_kwargs, build_grounding_config_path
from openspace.host_detection import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
load_runtime_env()
cli_overrides = {}
if args.max_iterations is not None:
@ -494,4 +500,4 @@ def run_main():
if __name__ == "__main__":
run_main()
run_main()

View file

@ -185,8 +185,17 @@ class GroundingAgent(BaseAgent):
conversation_messages.append(msg)
recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else []
truncated = system_messages.copy()
dropped = len(conversation_messages) - len(recent_messages)
if dropped > 0:
truncated.append({
"role": "system",
"content": (
f"{self._ITERATION_GUIDANCE_PREFIX} {dropped} earlier messages were "
"truncated to save context. The original task instruction is preserved below."
),
})
if user_instruction:
truncated.append(user_instruction)
truncated.extend(recent_messages)
@ -350,7 +359,7 @@ class GroundingAgent(BaseAgent):
f"Tool results: {len(tool_results_this_iteration)}, "
f"Content length: {len(assistant_content)} chars")
if len(assistant_content) > 0:
if len(assistant_content.strip()) > 0:
logger.info(f"Iteration {current_iteration} - Assistant content preview: {repr(assistant_content[:300])}")
consecutive_empty_responses = 0 # Reset counter on valid response
else:
@ -747,9 +756,19 @@ class GroundingAgent(BaseAgent):
}
})
# Use dedicated visual analysis model if configured, otherwise use main LLM model
# Resolve visual-model credentials independently when the visual
# model differs from the main reasoning model.
visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5")
_llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) if self._llm_client else {}
_llm_extra = {}
if self._llm_client and visual_model == self._llm_client.model:
_llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) or {}
elif self._visual_analysis_model:
try:
from openspace.host_detection import build_llm_kwargs
visual_model, _llm_extra = build_llm_kwargs(visual_model)
except Exception as e:
logger.debug(f"Failed to resolve dedicated visual model credentials: {e}")
_llm_extra = {}
response = await asyncio.wait_for(
litellm.acompletion(
model=visual_model,
@ -1218,4 +1237,4 @@ class GroundingAgent(BaseAgent):
"step": self.step,
"instruction": instruction,
}
)
)

View file

@ -21,7 +21,11 @@ Supported host agents:
import logging
from typing import Dict, Optional
from openspace.host_detection.resolver import build_llm_kwargs, build_grounding_config_path
from openspace.host_detection.resolver import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
from openspace.host_detection.nanobot import (
get_openai_api_key as _nanobot_get_openai_api_key,
read_nanobot_mcp_env,
@ -29,6 +33,7 @@ from openspace.host_detection.nanobot import (
)
from openspace.host_detection.openclaw import (
get_openclaw_openai_api_key as _openclaw_get_openai_api_key,
is_openclaw_host,
read_openclaw_skill_env,
try_read_openclaw_config,
)
@ -80,10 +85,12 @@ def get_openai_api_key() -> Optional[str]:
__all__ = [
"build_llm_kwargs",
"build_grounding_config_path",
"load_runtime_env",
"get_openai_api_key",
"read_host_mcp_env",
"read_nanobot_mcp_env",
"try_read_nanobot_config",
"is_openclaw_host",
"read_openclaw_skill_env",
"try_read_openclaw_config",
]

View file

@ -279,6 +279,13 @@ def get_openclaw_openai_api_key() -> Optional[str]:
return None
def is_openclaw_host() -> bool:
"""Detect if the current environment is running under OpenClaw."""
if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"):
return True
return _resolve_openclaw_config_path() is not None
def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]:
"""Read LLM credentials from OpenClaw's env-style config blocks."""
env_block = _get_openclaw_env("openspace")
@ -306,4 +313,3 @@ def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]:
return result

View file

@ -62,6 +62,11 @@ def _load_env_once() -> None:
load_dotenv()
def load_runtime_env() -> None:
"""Public wrapper for one-time runtime .env loading."""
_load_env_once()
def _pick_first_env(names: tuple[str, ...]) -> str:
for name in names:
value = os.environ.get(name, "").strip()

View file

@ -9,8 +9,9 @@ from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus
from openspace.grounding.core.tool import BaseTool
from openspace.utils.logging import Logger
# .env loading is centralized in host_detection.resolver._load_env_once()
# which is called by build_llm_kwargs / build_grounding_config_path.
# .env loading is centralized in host_detection.resolver.load_runtime_env().
# CLI/MCP entrypoints call it before reading startup env vars, and the
# resolver helpers also call it defensively.
# Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas
litellm.set_verbose = False
@ -169,9 +170,10 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]:
if not tool_name or not isinstance(tool_name, str):
return None
name = tool_name.strip()
# Dedup format: "server__toolname" -> use suffix
# Dedup format: "server__toolname" -> use suffix.
# Use rsplit to handle server names that themselves contain "__".
if "__" in name:
name = name.split("__", 1)[-1]
name = name.rsplit("__", 1)[-1]
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
if name in shell_tools:
return "shell"

View file

@ -137,7 +137,13 @@ async def _get_openspace():
logger.info("Initializing OpenSpace engine ...")
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
from openspace.host_detection import build_llm_kwargs, build_grounding_config_path
from openspace.host_detection import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
load_runtime_env()
env_model = os.environ.get("OPENSPACE_MODEL", "")
workspace = os.environ.get("OPENSPACE_WORKSPACE")

View file

@ -668,8 +668,9 @@ class RecordingManager:
if not tool_name or not isinstance(tool_name, str):
return None
name = tool_name.strip()
# Use rsplit to handle server names that themselves contain "__".
if "__" in name:
name = name.split("__", 1)[-1]
name = name.rsplit("__", 1)[-1]
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
if name in shell_tools:
return "shell"

View file

@ -84,13 +84,17 @@ def _correct_skill_ids(
if prefix and k.split("__")[0] == prefix
]
best, best_dist = None, 4 # threshold: edit distance ≤ 3
# Adaptive threshold: tighten when many candidates share the prefix
max_dist = 2 if len(candidates) > 20 else 4 # ≤1 or ≤3
best, best_dist, ambiguous = None, max_dist, False
for cand in candidates:
d = _edit_distance(raw_id, cand)
if d < best_dist:
best, best_dist = cand, d
best, best_dist, ambiguous = cand, d, False
elif d == best_dist and cand != best:
ambiguous = True # multiple candidates at same distance
if best is not None:
if best is not None and not ambiguous:
logger.info(
f"Corrected LLM skill ID: {raw_id!r} → {best!r} "
f"(edit_distance={best_dist})"

View file

@ -201,6 +201,7 @@ class SkillEvolver:
# evolved for each degraded tool. Keyed by tool_key.
# Pruned when a tool leaves the problematic list (= recovered).
self._addressed_degradations: Dict[str, Set[str]] = {}
self._degradation_lock = asyncio.Lock()
# Track background tasks so they can be awaited on shutdown.
self._background_tasks: Set[asyncio.Task] = set()
@ -219,7 +220,10 @@ class SkillEvolver:
f"Waiting for {len(self._background_tasks)} background "
f"evolution task(s) to finish..."
)
await asyncio.gather(*self._background_tasks, return_exceptions=True)
results = await asyncio.gather(*self._background_tasks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
logger.warning(f"Background evolution task failed during shutdown: {r}")
self._background_tasks.clear()
async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]:
@ -306,6 +310,13 @@ class SkillEvolver:
if not problematic_tools:
return []
async with self._degradation_lock:
return await self._process_tool_degradation_locked(problematic_tools)
async def _process_tool_degradation_locked(
self, problematic_tools: List,
) -> List[SkillRecord]:
"""Inner body of process_tool_degradation, called under _degradation_lock."""
# Prune recovered tools: if a tool_key used to be tracked but is
# no longer in the current problematic list, it recovered — clear
# its addressed set so future re-degradation gets a fresh pass.

View file

@ -934,4 +934,4 @@ class OpenSpace:
if self._running:
status = "running"
backends = ", ".join(self.config.backend_scope) if self.config.backend_scope else "all"
return f"<OpenSpace(status={status}, backends={backends}, model={self.config.llm_model})>"
return f"<OpenSpace(status={status}, backends={backends}, model={self.config.llm_model})>"