```
feat(engine): 添加技能查看工具并优化异步任务管理 - 添加SkillViewTool到引擎加载器中,增强技能管理功能 - 在AgentLoop中引入_active_direct_task来跟踪活跃任务 - 实现直接任务执行时的同步处理逻辑 - 更新工具实例化方式以支持依赖注入 feat(config): 增加智能体运行时参数配置支持 - 扩展AgentDefaultsConfig添加max_tokens和temperature字段 - 实现配置解析函数_first_config_value处理多个配置源 - 支持通过Web API动态更新智能体运行时参数 - 添加前端页面配置表单和验证逻辑 refactor(provider): 统一最大令牌数参数类型为可选整型 - 将所有LLM提供者的max_tokens参数改为int | None类型 - 为AnthropicProvider实现模型特定的最大令牌数默认值 - 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值 - 移除硬编码的默认值,改用条件判断 feat(process): 增强事件投影功能 - 添加工具调用开始/结束事件的映射逻辑 - 实现技能激活事件的识别和展示 - 添加辅助函数处理工具调用名称和参数提取 - 优化运行记录关联逻辑,提升事件匹配准确性 fix(web): 更新网络请求客户端信任环境设置 - 将WebFetchTool和WebSearchTool的trust_env参数设为True - 确保HTTP客户端能够正确使用系统代理配置 - 修复可能的网络连接问题 test: 添加配置加载和事件投影相关测试 - 新增智能体默认参数配置测试用例 - 实现API配置持久化和重载测试 - 添加技能卡片和工具事件的投影测试 ```
This commit is contained in:
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
|
||||
|
||||
|
||||
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
|
||||
return AgentRunResult(
|
||||
session_id="web:test",
|
||||
run_id=run_id,
|
||||
output_text=output_text,
|
||||
finish_reason="stop",
|
||||
tool_iterations=0,
|
||||
)
|
||||
|
||||
|
||||
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
|
||||
async def run_case() -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
|
||||
calls.append(task)
|
||||
if task == "outer":
|
||||
return await loop.submit_direct("inner", session_id="web:test")
|
||||
return _run_result(task, "inner completed")
|
||||
|
||||
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
|
||||
|
||||
loop_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
|
||||
finally:
|
||||
await loop.stop()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(loop_task, timeout=1)
|
||||
if not loop_task.done():
|
||||
loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await loop_task
|
||||
|
||||
assert result.output_text == "inner completed"
|
||||
assert calls == ["outer", "inner"]
|
||||
|
||||
asyncio.run(run_case())
|
||||
@ -1,10 +1,12 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
from beaver.foundation.config import load_config
|
||||
from beaver.interfaces.web.app import _reload_agent_config
|
||||
from beaver.interfaces.web.app import create_app, _reload_agent_config
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@ -161,6 +163,88 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"maxTokens": 12345,
|
||||
"temperature": 0.4,
|
||||
"maxToolIterations": 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
service = AgentService(config_path=config_path)
|
||||
|
||||
assert config.agents_defaults.max_tokens == 12345
|
||||
assert config.agents_defaults.temperature == 0.4
|
||||
assert config.agents_defaults.max_tool_iterations == 9
|
||||
assert service.profile.max_tokens == 12345
|
||||
assert service.profile.temperature == 0.4
|
||||
assert service.profile.max_tool_iterations == 9
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
|
||||
)
|
||||
status = client.get("/api/status")
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
defaults = saved["agents"]["defaults"]
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert defaults["maxTokens"] == 8192
|
||||
assert defaults["temperature"] == 0.6
|
||||
assert defaults["maxToolIterations"] == 12
|
||||
assert service.profile.max_tokens == 8192
|
||||
assert service.profile.temperature == 0.6
|
||||
assert service.profile.max_tool_iterations == 12
|
||||
assert status.json()["max_tokens"] == 8192
|
||||
assert status.json()["temperature"] == 0.6
|
||||
assert status.json()["max_tool_iterations"] == 12
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert config.agents_defaults.max_tokens is None
|
||||
assert config.agents_defaults.temperature == 0
|
||||
assert config.agents_defaults.max_tool_iterations == 0
|
||||
assert service.profile.max_tokens is None
|
||||
assert service.profile.temperature == 0
|
||||
assert service.profile.max_tool_iterations == 0
|
||||
service.close()
|
||||
|
||||
|
||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
bundle = make_provider_bundle(
|
||||
model="qwen-plus",
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"cron-scheduler": ["cron"],
|
||||
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
|
||||
"memory-management": ["memory"],
|
||||
"outlook-mail": [
|
||||
"mcp_outlook_mcp_mail_list_folders",
|
||||
"mcp_outlook_mcp_mail_list_messages",
|
||||
"mcp_outlook_mcp_mail_search_messages",
|
||||
"mcp_outlook_mcp_mail_get_message",
|
||||
"mcp_outlook_mcp_mail_send_email",
|
||||
"mcp_outlook_mcp_mail_reply_to_message",
|
||||
"mcp_outlook_mcp_mail_forward_message",
|
||||
"mcp_outlook_mcp_mail_move_message",
|
||||
"mcp_outlook_mcp_mail_delta_sync",
|
||||
"mcp_outlook_mcp_calendar_list_events",
|
||||
"mcp_outlook_mcp_calendar_create_event",
|
||||
"mcp_outlook_mcp_calendar_update_event",
|
||||
"mcp_outlook_mcp_calendar_get_schedule",
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
}
|
||||
|
||||
|
||||
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
assert "skill_view" in loaded.tools
|
||||
assert loaded.tool_registry is not None
|
||||
assert loaded.tool_registry.get("skill_view") is not None
|
||||
finally:
|
||||
loaded.close()
|
||||
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.loop import AgentProfile
|
||||
from beaver.engine.providers.anthropic import AnthropicProvider
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
|
||||
|
||||
def test_agent_profile_uses_provider_output_default() -> None:
|
||||
assert AgentProfile().max_tokens is None
|
||||
|
||||
|
||||
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="ok", tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
|
||||
async def run_case():
|
||||
provider = LiteLLMProvider(default_model="openai/gpt-test")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert "max_tokens" not in captured_kwargs
|
||||
|
||||
|
||||
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
class FakeMessages:
|
||||
async def create(self, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
usage=None,
|
||||
stop_reason="stop",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
messages = FakeMessages()
|
||||
|
||||
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
|
||||
|
||||
async def run_case():
|
||||
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert captured_kwargs["max_tokens"] == 64_000
|
||||
@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||
from beaver.services.process_service import SessionProcessProjector
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
@ -238,6 +239,130 @@ def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Pat
|
||||
assert planned_event["metadata"]["strategy"] == "single"
|
||||
|
||||
|
||||
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
activated_skills=[
|
||||
SkillActivationReceipt(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
skill_name="web-operation",
|
||||
skill_version="1",
|
||||
content_hash="hash",
|
||||
activated_at="2026-01-01T00:00:03+00:00",
|
||||
activation_reason="Needs live web lookup.",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": "single",
|
||||
"strategy": "single",
|
||||
"selected_skill_names": [],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_synthesis_completed",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
skill_events = [
|
||||
event
|
||||
for event in projection["events"]
|
||||
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
|
||||
]
|
||||
assert skill_events
|
||||
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
|
||||
|
||||
|
||||
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="assistant",
|
||||
event_type="assistant_message_added",
|
||||
event_payload={"task_id": "task-1"},
|
||||
content="Searching",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"name": "multi_search",
|
||||
"arguments": {"query": "Macau cafe near Bóvia"},
|
||||
}
|
||||
],
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="tool",
|
||||
event_type="tool_result_recorded",
|
||||
event_payload={"success": True, "error": None},
|
||||
content="Found 3 restaurants",
|
||||
tool_name="multi_search",
|
||||
tool_call_id="call-1",
|
||||
context_visible=True,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
|
||||
assert tool_call["metadata"]["timeline_type"] == "tool_call"
|
||||
assert tool_call["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_call["run_id"] == "main-run"
|
||||
|
||||
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
|
||||
assert tool_result["metadata"]["timeline_type"] == "tool_result"
|
||||
assert tool_result["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_result["metadata"]["success"] is True
|
||||
|
||||
|
||||
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
|
||||
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.tools.builtins import web
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
headers = {"content-type": "text/html"}
|
||||
status_code = 200
|
||||
text = '<a class="result__a" href="https://example.com">Example</a>'
|
||||
url = "https://example.com"
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
|
||||
_FakeAsyncClient.calls = []
|
||||
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
|
||||
async def _run() -> None:
|
||||
await web.WebFetchTool().execute(url="https://example.com")
|
||||
await web.WebSearchTool().execute(query="example")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]
|
||||
Reference in New Issue
Block a user