Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -309,7 +309,9 @@ class AgentLoop:
|
|||||||
|
|
||||||
async def _do_restart():
|
async def _do_restart():
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
# Use -m nanobot instead of sys.argv[0] for Windows compatibility
|
||||||
|
# (sys.argv[0] may be just "nanobot" without full path on Windows)
|
||||||
|
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
|
||||||
|
|
||||||
asyncio.create_task(_do_restart())
|
asyncio.create_task(_do_restart())
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ def _normalize_save_memory_args(args: Any) -> dict[str, Any] | None:
|
|||||||
return args[0] if args and isinstance(args[0], dict) else None
|
return args[0] if args and isinstance(args[0], dict) else None
|
||||||
return args if isinstance(args, dict) else None
|
return args if isinstance(args, dict) else None
|
||||||
|
|
||||||
|
_TOOL_CHOICE_ERROR_MARKERS = (
|
||||||
|
"tool_choice",
|
||||||
|
"toolchoice",
|
||||||
|
"does not support",
|
||||||
|
'should be ["none", "auto"]',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tool_choice_unsupported(content: str | None) -> bool:
|
||||||
|
"""Detect provider errors caused by forced tool_choice being unsupported."""
|
||||||
|
text = (content or "").lower()
|
||||||
|
return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
class MemoryStore:
|
class MemoryStore:
|
||||||
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
|
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
|
||||||
|
|
||||||
@@ -118,15 +132,33 @@ class MemoryStore:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
forced = {"type": "function", "function": {"name": "save_memory"}}
|
||||||
response = await provider.chat_with_retry(
|
response = await provider.chat_with_retry(
|
||||||
messages=chat_messages,
|
messages=chat_messages,
|
||||||
tools=_SAVE_MEMORY_TOOL,
|
tools=_SAVE_MEMORY_TOOL,
|
||||||
model=model,
|
model=model,
|
||||||
tool_choice={"type": "function", "function": {"name": "save_memory"}},
|
tool_choice=forced,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.finish_reason == "error" and _is_tool_choice_unsupported(
|
||||||
|
response.content
|
||||||
|
):
|
||||||
|
logger.warning("Forced tool_choice unsupported, retrying with auto")
|
||||||
|
response = await provider.chat_with_retry(
|
||||||
|
messages=chat_messages,
|
||||||
|
tools=_SAVE_MEMORY_TOOL,
|
||||||
|
model=model,
|
||||||
|
tool_choice="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.has_tool_calls:
|
if not response.has_tool_calls:
|
||||||
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
|
logger.warning(
|
||||||
|
"Memory consolidation: LLM did not call save_memory "
|
||||||
|
"(finish_reason={}, content_len={}, content_preview={})",
|
||||||
|
response.finish_reason,
|
||||||
|
len(response.content or ""),
|
||||||
|
(response.content or "")[:200],
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
args = _normalize_save_memory_args(response.tool_calls[0].arguments)
|
args = _normalize_save_memory_args(response.tool_calls[0].arguments)
|
||||||
|
|||||||
@@ -114,16 +114,16 @@ class QQChannel(BaseChannel):
|
|||||||
if msg_type == "group":
|
if msg_type == "group":
|
||||||
await self._client.api.post_group_message(
|
await self._client.api.post_group_message(
|
||||||
group_openid=msg.chat_id,
|
group_openid=msg.chat_id,
|
||||||
msg_type=2,
|
msg_type=0,
|
||||||
markdown={"content": msg.content},
|
content=msg.content,
|
||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
msg_seq=self._msg_seq,
|
msg_seq=self._msg_seq,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self._client.api.post_c2c_message(
|
await self._client.api.post_c2c_message(
|
||||||
openid=msg.chat_id,
|
openid=msg.chat_id,
|
||||||
msg_type=2,
|
msg_type=0,
|
||||||
markdown={"content": msg.content},
|
content=msg.content,
|
||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
msg_seq=self._msg_seq,
|
msg_seq=self._msg_seq,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,13 +75,6 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.metadata]
|
[tool.hatch.metadata]
|
||||||
allow-direct-references = true
|
allow-direct-references = true
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["nanobot"]
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.sources]
|
|
||||||
"nanobot" = "nanobot"
|
|
||||||
|
|
||||||
# Include non-Python files in skills and templates
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/**/*.py",
|
"nanobot/**/*.py",
|
||||||
@@ -90,6 +83,15 @@ include = [
|
|||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["nanobot"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.sources]
|
||||||
|
"nanobot" = "nanobot"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
|
"bridge" = "nanobot/bridge"
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/",
|
"nanobot/",
|
||||||
@@ -98,9 +100,6 @@ include = [
|
|||||||
"LICENSE",
|
"LICENSE",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
|
||||||
"bridge" = "nanobot/bridge"
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|||||||
@@ -288,3 +288,60 @@ class TestMemoryConsolidationTypeHandling:
|
|||||||
assert "temperature" not in kwargs
|
assert "temperature" not in kwargs
|
||||||
assert "max_tokens" not in kwargs
|
assert "max_tokens" not in kwargs
|
||||||
assert "reasoning_effort" not in kwargs
|
assert "reasoning_effort" not in kwargs
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_choice_fallback_on_unsupported_error(self, tmp_path: Path) -> None:
|
||||||
|
"""Forced tool_choice rejected by provider -> retry with auto and succeed."""
|
||||||
|
store = MemoryStore(tmp_path)
|
||||||
|
error_resp = LLMResponse(
|
||||||
|
content="Error calling LLM: litellm.BadRequestError: "
|
||||||
|
"The tool_choice parameter does not support being set to required or object",
|
||||||
|
finish_reason="error",
|
||||||
|
tool_calls=[],
|
||||||
|
)
|
||||||
|
ok_resp = _make_tool_response(
|
||||||
|
history_entry="[2026-01-01] Fallback worked.",
|
||||||
|
memory_update="# Memory\nFallback OK.",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_log: list[dict] = []
|
||||||
|
|
||||||
|
async def _tracking_chat(**kwargs):
|
||||||
|
call_log.append(kwargs)
|
||||||
|
return error_resp if len(call_log) == 1 else ok_resp
|
||||||
|
|
||||||
|
provider = AsyncMock()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=_tracking_chat)
|
||||||
|
messages = _make_messages(message_count=60)
|
||||||
|
|
||||||
|
result = await store.consolidate(messages, provider, "test-model")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert len(call_log) == 2
|
||||||
|
assert isinstance(call_log[0]["tool_choice"], dict)
|
||||||
|
assert call_log[1]["tool_choice"] == "auto"
|
||||||
|
assert "Fallback worked." in store.history_file.read_text()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_choice_fallback_auto_no_tool_call(self, tmp_path: Path) -> None:
|
||||||
|
"""Forced rejected, auto retry also produces no tool call -> return False."""
|
||||||
|
store = MemoryStore(tmp_path)
|
||||||
|
error_resp = LLMResponse(
|
||||||
|
content="Error: tool_choice must be none or auto",
|
||||||
|
finish_reason="error",
|
||||||
|
tool_calls=[],
|
||||||
|
)
|
||||||
|
no_tool_resp = LLMResponse(
|
||||||
|
content="Here is a summary.",
|
||||||
|
finish_reason="stop",
|
||||||
|
tool_calls=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = AsyncMock()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=[error_resp, no_tool_resp])
|
||||||
|
messages = _make_messages(message_count=60)
|
||||||
|
|
||||||
|
result = await store.consolidate(messages, provider, "test-model")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert not store.history_file.exists()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async def test_on_group_message_routes_to_group_chat_id() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_group_message_uses_group_api_with_msg_seq() -> None:
|
async def test_send_group_message_uses_plain_text_group_api_with_msg_seq() -> None:
|
||||||
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
||||||
channel._client = _FakeClient()
|
channel._client = _FakeClient()
|
||||||
channel._chat_type_cache["group123"] = "group"
|
channel._chat_type_cache["group123"] = "group"
|
||||||
@@ -60,7 +60,37 @@ async def test_send_group_message_uses_group_api_with_msg_seq() -> None:
|
|||||||
|
|
||||||
assert len(channel._client.api.group_calls) == 1
|
assert len(channel._client.api.group_calls) == 1
|
||||||
call = channel._client.api.group_calls[0]
|
call = channel._client.api.group_calls[0]
|
||||||
assert call["group_openid"] == "group123"
|
assert call == {
|
||||||
assert call["msg_id"] == "msg1"
|
"group_openid": "group123",
|
||||||
assert call["msg_seq"] == 2
|
"msg_type": 0,
|
||||||
|
"content": "hello",
|
||||||
|
"msg_id": "msg1",
|
||||||
|
"msg_seq": 2,
|
||||||
|
}
|
||||||
assert not channel._client.api.c2c_calls
|
assert not channel._client.api.c2c_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_c2c_message_uses_plain_text_c2c_api_with_msg_seq() -> None:
|
||||||
|
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
||||||
|
channel._client = _FakeClient()
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="qq",
|
||||||
|
chat_id="user123",
|
||||||
|
content="hello",
|
||||||
|
metadata={"message_id": "msg1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(channel._client.api.c2c_calls) == 1
|
||||||
|
call = channel._client.api.c2c_calls[0]
|
||||||
|
assert call == {
|
||||||
|
"openid": "user123",
|
||||||
|
"msg_type": 0,
|
||||||
|
"content": "hello",
|
||||||
|
"msg_id": "msg1",
|
||||||
|
"msg_seq": 2,
|
||||||
|
}
|
||||||
|
assert not channel._client.api.group_calls
|
||||||
|
|||||||
Reference in New Issue
Block a user