Compare commits

...

8 Commits

Author SHA1 Message Date
Alexander Whitestone
e6c346c6bb feat: time-aware model routing for cron jobs (#317)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m5s
Route cron jobs to different models based on time of day.

- Peak hours (user active): cheaper model, user catches errors
- Off-peak hours (user absent): stronger model, errors go uncorrected

Config under smart_model_routing.cron_time_routing:
  enabled, timezone, peak_hours (start/end), peak_model, offpeak_model

Integrates into cron/scheduler.py via resolve_cron_turn_route(), which
checks time-aware routing first, then falls back to normal smart routing.

12 tests added (test_cron_time_routing.py). All 18 routing tests pass.

Closes #317
2026-04-13 17:30:07 -04:00
1ec02cf061 Merge pull request 'fix(gateway): reject known-weak placeholder tokens at startup' (#371) from fix/weak-credential-guard into main
Some checks failed
Forge CI / smoke-and-build (push) Failing after 3m6s
2026-04-13 20:33:00 +00:00
Alexander Whitestone
1156875cb5 fix(gateway): reject known-weak placeholder tokens at startup
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 3m8s
Fixes #318

Cherry-picked concept from ferris fork (f724079).

Problem: Users who copy .env.example without changing values
get confusing auth failures at gateway startup.

Fix: _guard_weak_credentials() checks TELEGRAM_BOT_TOKEN,
DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, HASS_TOKEN against
known-weak placeholder patterns (your-token-here, fake, xxx,
etc.) and minimum length requirements. Warns at startup.

Tests: 6 tests (no tokens, placeholder, case-insensitive,
short token, valid pass-through, multiple weak). All pass.
2026-04-13 16:32:56 -04:00
f4c102400e Merge pull request 'feat(memory): enable temporal decay with access-recency boost — #241' (#367) from feat/temporal-decay-holographic-memory into main
Some checks failed
Forge CI / smoke-and-build (push) Failing after 31s
Merge PR #367: feat(memory): enable temporal decay with access-recency boost
2026-04-13 19:51:04 +00:00
6555ccabc1 Merge pull request 'fix(tools): validate handler return types at dispatch boundary' (#369) from fix/tool-return-type-validation into main
Some checks failed
Forge CI / smoke-and-build (push) Failing after 21s
2026-04-13 19:47:56 +00:00
Alexander Whitestone
8c712866c4 fix(tools): validate handler return types at dispatch boundary
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 22s
Fixes #297

Problem: Tool handlers that return dict/list/None instead of a
JSON string crash the agent loop with cryptic errors. No error
proofing at the boundary.
Fix: In handle_function_call(), after dispatch returns:
1. If result is not str → wrap in JSON with _type_warning
2. If result is str but not valid JSON → wrap in {"output": ...}
3. Log type violations for analysis
4. Valid JSON strings pass through unchanged

Tests: 4 new tests (dict, None, non-JSON string, valid JSON).
All 16 tests in test_model_tools.py pass.
2026-04-13 15:47:52 -04:00
8fb59aae64 Merge pull request 'fix(tools): memory no-match is success, not error' (#368) from fix/memory-no-match-not-error into main
Some checks failed
Forge CI / smoke-and-build (push) Failing after 22s
2026-04-13 19:41:08 +00:00
Alexander Whitestone
95bde9d3cb fix(tools): memory no-match is success, not error
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 24s
Fixes #313

Problem: MemoryStore.replace() and .remove() return
{"success": false, "error": "No entry matched..."} when the
search substring is not found. This is a valid outcome, not
an error. The empirical audit showed 58.4% error rate on the
memory tool, but 98.4% of those were just empty search results.

Fix: Return {"success": true, "result": "no_match", "message": ...}
instead. This drops the memory tool error rate from ~58% to ~1%.

Tests updated: test_replace_no_match and test_remove_no_match
now assert success=True with result="no_match".
All 33 memory tool tests pass.
2026-04-13 15:40:48 -04:00
11 changed files with 563 additions and 17 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import os
import re
from datetime import datetime
from typing import Any, Dict, Optional
from utils import is_truthy_value
@@ -182,7 +183,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
},
"label": f"smart route {route.get('model')} ({runtime.get('provider')})",
"label": f"smart route \u2192 {route.get('model')} ({runtime.get('provider')})",
"signature": (
route.get("model"),
runtime.get("provider"),
@@ -192,3 +193,151 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
tuple(runtime.get("args") or ()),
),
}
# ---------------------------------------------------------------------------
# Time-aware cron model routing
# ---------------------------------------------------------------------------
# During peak hours (user active), cron jobs use a cheaper model because the
# user is present to catch and correct errors. During off-peak hours (user
# absent), cron jobs use a stronger model because errors go uncorrected.
#
# Config (under smart_model_routing.cron_time_routing):
# enabled: true
# timezone: "America/New_York" # IANA timezone name (default: UTC)
# peak_hours:
# start: 9 # inclusive, 0-23
# end: 18 # exclusive, 0-23
# peak_model: # model to use during peak hours
# provider: openrouter
# model: xiaomi/mimo-v2-pro
# offpeak_model: # model to use during off-peak hours
# provider: openrouter
# model: anthropic/claude-sonnet-4
def _get_current_hour_in_tz(tz_name: str) -> int:
"""Return the current hour (0-23) in the given IANA timezone."""
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(tz_name)
except Exception:
try:
import pytz
tz = pytz.timezone(tz_name)
except Exception:
return datetime.utcnow().hour
return datetime.now(tz).hour
def _is_peak_hour(hour: int, peak_start: int, peak_end: int) -> bool:
"""Return True if *hour* falls within [peak_start, peak_end).
Handles wrap-around (e.g. start=22, end=6 means 22:00-05:59 is peak).
"""
if peak_start <= peak_end:
return peak_start <= hour < peak_end
else:
# Wraps midnight: e.g. 22-6 means 22,23,0,1,2,3,4,5
return hour >= peak_start or hour < peak_end
def resolve_cron_time_route(
routing_config: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Return a time-aware model override for cron jobs.
Considers the current hour in the configured timezone and picks
between a peak-hours model (cheaper, user present) and an off-peak
model (stronger, user absent, errors go uncorrected).
Returns None when time-aware routing is disabled or misconfigured,
so the caller falls through to normal routing.
"""
cfg = routing_config or {}
cron_cfg = cfg.get("cron_time_routing") or {}
if not _coerce_bool(cron_cfg.get("enabled"), False):
return None
tz_name = str(cron_cfg.get("timezone", "UTC")).strip()
peak = cron_cfg.get("peak_hours") or {}
peak_start = _coerce_int(peak.get("start"), 9)
peak_end = _coerce_int(peak.get("end"), 18)
current_hour = _get_current_hour_in_tz(tz_name)
is_peak = _is_peak_hour(current_hour, peak_start, peak_end)
if is_peak:
model_cfg = cron_cfg.get("peak_model") or {}
reason = "cron_peak_hours"
else:
model_cfg = cron_cfg.get("offpeak_model") or {}
reason = "cron_offpeak_hours"
provider = str(model_cfg.get("provider") or "").strip().lower()
model = str(model_cfg.get("model") or "").strip()
if not provider or not model:
return None
return {
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"api_key_env": model_cfg.get("api_key_env", ""),
"routing_reason": reason,
"is_peak_hour": is_peak,
"hour": current_hour,
}
def resolve_cron_turn_route(
user_message: str,
routing_config: Optional[Dict[str, Any]],
primary: Dict[str, Any],
) -> Dict[str, Any]:
"""Resolve model route for a cron job turn with time-awareness.
Checks time-aware routing first (cron_time_routing), then falls
back to normal smart routing, then falls back to primary.
"""
# 1. Time-aware cron routing (peak vs off-peak)
time_route = resolve_cron_time_route(routing_config)
if time_route:
from hermes_cli.runtime_provider import resolve_runtime_provider
explicit_api_key = None
api_key_env = str(time_route.get("api_key_env") or "").strip()
if api_key_env:
explicit_api_key = os.getenv(api_key_env) or None
try:
runtime = resolve_runtime_provider(
requested=time_route.get("provider"),
explicit_api_key=explicit_api_key,
explicit_base_url=time_route.get("base_url"),
)
peak_label = "peak" if time_route.get("is_peak_hour") else "off-peak"
return {
"model": time_route.get("model"),
"runtime": {
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
"provider": runtime.get("provider"),
"api_mode": runtime.get("api_mode"),
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
},
"label": f"cron {peak_label} -> {time_route.get('model')} ({runtime.get('provider')})",
"signature": (
time_route.get("model"),
runtime.get("provider"),
runtime.get("base_url"),
runtime.get("api_mode"),
runtime.get("command"),
tuple(runtime.get("args") or ()),
),
}
except Exception:
pass # Fall through to normal routing
# 2. Normal smart routing (simple-turn cheap model)
return resolve_turn_route(user_message, routing_config, primary)

View File

@@ -87,6 +87,21 @@ model:
# cheap_model:
# provider: openrouter
# model: google/gemini-2.5-flash
# # Time-aware cron routing: pick model based on hour of day.
# # Peak hours = user present, cheaper model OK (they catch errors).
# # Off-peak = user absent, stronger model (errors go uncorrected).
# cron_time_routing:
# enabled: true
# timezone: "America/New_York" # IANA timezone (default: UTC)
# peak_hours:
# start: 9 # inclusive, 0-23
# end: 18 # exclusive, 0-23
# peak_model: # model during peak hours (user active)
# provider: openrouter
# model: xiaomi/mimo-v2-pro
# offpeak_model: # model during off-peak (user absent)
# provider: openrouter
# model: anthropic/claude-sonnet-4
# =============================================================================
# Git Worktree Isolation

View File

@@ -762,8 +762,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
message = format_runtime_provider_error(exc)
raise RuntimeError(message) from exc
from agent.smart_model_routing import resolve_turn_route
turn_route = resolve_turn_route(
from agent.smart_model_routing import resolve_cron_turn_route
turn_route = resolve_cron_turn_route(
prompt,
smart_routing,
{
@@ -776,6 +776,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"args": list(runtime.get("args") or []),
},
)
if turn_route.get("label"):
logger.info("Job '%s': %s", job_name, turn_route["label"])
_agent_kwargs = _safe_agent_kwargs({
"model": turn_route["model"],

View File

@@ -648,6 +648,51 @@ def load_gateway_config() -> GatewayConfig:
return config
# Known-weak placeholder tokens from .env.example, tutorials, etc.
_WEAK_TOKEN_PATTERNS = {
"your-token-here", "your_token_here", "your-token", "your_token",
"change-me", "change_me", "changeme",
"xxx", "xxxx", "xxxxx", "xxxxxxxx",
"test", "testing", "fake", "placeholder",
"replace-me", "replace_me", "replace this",
"insert-token-here", "put-your-token",
"bot-token", "bot_token",
"sk-xxxxxxxx", "sk-placeholder",
"BOT_TOKEN_HERE", "YOUR_BOT_TOKEN",
}
# Minimum token lengths by platform (tokens shorter than these are invalid)
_MIN_TOKEN_LENGTHS = {
"TELEGRAM_BOT_TOKEN": 30,
"DISCORD_BOT_TOKEN": 50,
"SLACK_BOT_TOKEN": 20,
"HASS_TOKEN": 20,
}
def _guard_weak_credentials() -> list[str]:
"""Check env vars for known-weak placeholder tokens.
Returns a list of warning messages for any weak credentials found.
"""
warnings = []
for env_var, min_len in _MIN_TOKEN_LENGTHS.items():
value = os.getenv(env_var, "").strip()
if not value:
continue
if value.lower() in _WEAK_TOKEN_PATTERNS:
warnings.append(
f"{env_var} is set to a placeholder value ('{value[:20]}'). "
f"Replace it with a real token."
)
elif len(value) < min_len:
warnings.append(
f"{env_var} is suspiciously short ({len(value)} chars, "
f"expected >{min_len}). May be truncated or invalid."
)
return warnings
def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config."""
@@ -941,3 +986,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.default_reset_policy.at_hour = int(reset_hour)
except ValueError:
pass
# Guard against weak placeholder tokens from .env.example copies
for warning in _guard_weak_credentials():
logger.warning("Weak credential: %s", warning)

View File

@@ -299,6 +299,13 @@ DEFAULT_CONFIG = {
"max_simple_chars": 160,
"max_simple_words": 28,
"cheap_model": {},
"cron_time_routing": {
"enabled": False,
"timezone": "UTC",
"peak_hours": {"start": 9, "end": 18},
"peak_model": {},
"offpeak_model": {},
},
},
# Auxiliary model config — provider:model for each side task.

View File

@@ -540,6 +540,29 @@ def handle_function_call(
except Exception:
pass
# Poka-yoke: validate tool handler return type.
# Handlers MUST return a JSON string. If they return dict/list/None,
# wrap the result so the agent loop doesn't crash with cryptic errors.
if not isinstance(result, str):
logger.warning(
"Tool '%s' returned %s instead of str — wrapping in JSON",
function_name, type(result).__name__,
)
result = json.dumps(
{"output": str(result), "_type_warning": f"Tool returned {type(result).__name__}, expected str"},
ensure_ascii=False,
)
else:
# Validate it's parseable JSON
try:
json.loads(result)
except (json.JSONDecodeError, TypeError):
logger.warning(
"Tool '%s' returned non-JSON string — wrapping in JSON",
function_name,
)
result = json.dumps({"output": result}, ensure_ascii=False)
return result
except Exception as e:

View File

@@ -0,0 +1,164 @@
"""Tests for time-aware cron model routing."""
from agent.smart_model_routing import (
_is_peak_hour,
resolve_cron_time_route,
resolve_cron_turn_route,
)
# ---------------------------------------------------------------------------
# _is_peak_hour
# ---------------------------------------------------------------------------
def test_peak_hour_within_normal_range():
assert _is_peak_hour(10, 9, 18) is True
assert _is_peak_hour(12, 9, 18) is True
assert _is_peak_hour(17, 9, 18) is True
def test_peak_hour_outside_normal_range():
assert _is_peak_hour(8, 9, 18) is False
assert _is_peak_hour(18, 9, 18) is False
assert _is_peak_hour(22, 9, 18) is False
assert _is_peak_hour(0, 9, 18) is False
def test_peak_hour_at_boundaries():
assert _is_peak_hour(9, 9, 18) is True # start inclusive
assert _is_peak_hour(18, 9, 18) is False # end exclusive
def test_peak_hour_wraps_midnight():
# 22-6 means peak from 22:00 to 05:59
assert _is_peak_hour(22, 22, 6) is True
assert _is_peak_hour(23, 22, 6) is True
assert _is_peak_hour(0, 22, 6) is True
assert _is_peak_hour(5, 22, 6) is True
assert _is_peak_hour(6, 22, 6) is False
assert _is_peak_hour(12, 22, 6) is False
assert _is_peak_hour(21, 22, 6) is False
# ---------------------------------------------------------------------------
# resolve_cron_time_route
# ---------------------------------------------------------------------------
_CRON_ROUTING_CFG = {
"cron_time_routing": {
"enabled": True,
"timezone": "UTC",
"peak_hours": {"start": 9, "end": 18},
"peak_model": {
"provider": "openrouter",
"model": "xiaomi/mimo-v2-pro",
},
"offpeak_model": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
},
},
}
def test_returns_none_when_disabled():
cfg = {"cron_time_routing": {"enabled": False}}
assert resolve_cron_time_route(cfg) is None
def test_returns_none_when_no_config():
assert resolve_cron_time_route(None) is None
assert resolve_cron_time_route({}) is None
def test_returns_none_when_models_missing():
cfg = {
"cron_time_routing": {
"enabled": True,
"peak_model": {"provider": "", "model": ""},
"offpeak_model": {"provider": "", "model": ""},
}
}
assert resolve_cron_time_route(cfg) is None
def test_returns_route_with_hour_injection(monkeypatch):
"""Force hour=14 (peak) via _get_current_hour_in_tz patch."""
monkeypatch.setattr(
"agent.smart_model_routing._get_current_hour_in_tz",
lambda tz: 14,
)
result = resolve_cron_time_route(_CRON_ROUTING_CFG)
assert result is not None
assert result["model"] == "xiaomi/mimo-v2-pro"
assert result["is_peak_hour"] is True
assert result["hour"] == 14
assert result["routing_reason"] == "cron_peak_hours"
def test_returns_offpeak_route(monkeypatch):
monkeypatch.setattr(
"agent.smart_model_routing._get_current_hour_in_tz",
lambda tz: 3,
)
result = resolve_cron_time_route(_CRON_ROUTING_CFG)
assert result is not None
assert result["model"] == "anthropic/claude-sonnet-4"
assert result["is_peak_hour"] is False
assert result["hour"] == 3
assert result["routing_reason"] == "cron_offpeak_hours"
# ---------------------------------------------------------------------------
# resolve_cron_turn_route
# ---------------------------------------------------------------------------
_PRIMARY = {
"model": "anthropic/claude-opus-4",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions",
"api_key": "***",
}
def test_cron_turn_route_uses_time_awareness(monkeypatch):
monkeypatch.setattr(
"agent.smart_model_routing._get_current_hour_in_tz",
lambda tz: 2, # off-peak
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"api_key": "test-key",
"base_url": "https://openrouter.ai/api/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
"command": None,
"args": [],
},
)
result = resolve_cron_turn_route("check status", _CRON_ROUTING_CFG, _PRIMARY)
assert result["model"] == "anthropic/claude-sonnet-4"
assert "cron off-peak" in (result.get("label") or "")
def test_cron_turn_route_falls_back_to_primary_when_no_config():
result = resolve_cron_turn_route("check status", None, _PRIMARY)
assert result["model"] == "anthropic/claude-opus-4"
assert result["label"] is None # no smart routing match
def test_cron_turn_route_falls_back_on_runtime_error(monkeypatch):
"""If time-route runtime resolution fails, fall back to normal routing."""
monkeypatch.setattr(
"agent.smart_model_routing._get_current_hour_in_tz",
lambda tz: 2,
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: (_ for _ in ()).throw(RuntimeError("bad")),
)
result = resolve_cron_turn_route("check status", _CRON_ROUTING_CFG, _PRIMARY)
# Falls back to primary since the time-route runtime failed
assert result["model"] == "anthropic/claude-opus-4"

View File

@@ -0,0 +1,52 @@
"""Tests for weak credential guard in gateway/config.py."""
import os
import pytest
from gateway.config import _guard_weak_credentials, _WEAK_TOKEN_PATTERNS, _MIN_TOKEN_LENGTHS
class TestWeakCredentialGuard:
"""Tests for _guard_weak_credentials()."""
def test_no_tokens_set(self, monkeypatch):
"""When no relevant tokens are set, no warnings."""
for var in _MIN_TOKEN_LENGTHS:
monkeypatch.delenv(var, raising=False)
warnings = _guard_weak_credentials()
assert warnings == []
def test_placeholder_token_detected(self, monkeypatch):
"""Known-weak placeholder tokens are flagged."""
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "your-token-here")
warnings = _guard_weak_credentials()
assert len(warnings) == 1
assert "TELEGRAM_BOT_TOKEN" in warnings[0]
assert "placeholder" in warnings[0].lower()
def test_case_insensitive_match(self, monkeypatch):
"""Placeholder detection is case-insensitive."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "FAKE")
warnings = _guard_weak_credentials()
assert len(warnings) == 1
assert "DISCORD_BOT_TOKEN" in warnings[0]
def test_short_token_detected(self, monkeypatch):
"""Suspiciously short tokens are flagged."""
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc123") # 6 chars, min is 30
warnings = _guard_weak_credentials()
assert len(warnings) == 1
assert "short" in warnings[0].lower()
def test_valid_token_passes(self, monkeypatch):
"""A long, non-placeholder token produces no warnings."""
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567")
warnings = _guard_weak_credentials()
assert warnings == []
def test_multiple_weak_tokens(self, monkeypatch):
"""Multiple weak tokens each produce a warning."""
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "change-me")
monkeypatch.setenv("DISCORD_BOT_TOKEN", "xx") # short
warnings = _guard_weak_credentials()
assert len(warnings) == 2

View File

@@ -137,3 +137,78 @@ class TestBackwardCompat:
def test_tool_to_toolset_map(self):
assert isinstance(TOOL_TO_TOOLSET_MAP, dict)
assert len(TOOL_TO_TOOLSET_MAP) > 0
class TestToolReturnTypeValidation:
"""Poka-yoke: tool handlers must return JSON strings."""
def test_handler_returning_dict_is_wrapped(self, monkeypatch):
"""A handler that returns a dict should be auto-wrapped to JSON string."""
from tools.registry import registry
from model_tools import handle_function_call
import json
# Register a bad handler that returns dict instead of str
registry.register(
name="__test_bad_dict",
toolset="test",
schema={"name": "__test_bad_dict", "description": "test", "parameters": {"type": "object", "properties": {}}},
handler=lambda args, **kw: {"this is": "a dict not a string"},
)
result = handle_function_call("__test_bad_dict", {})
parsed = json.loads(result)
assert "output" in parsed
assert "_type_warning" in parsed
# Cleanup
registry._tools.pop("__test_bad_dict", None)
def test_handler_returning_none_is_wrapped(self, monkeypatch):
"""A handler that returns None should be auto-wrapped."""
from tools.registry import registry
from model_tools import handle_function_call
import json
registry.register(
name="__test_bad_none",
toolset="test",
schema={"name": "__test_bad_none", "description": "test", "parameters": {"type": "object", "properties": {}}},
handler=lambda args, **kw: None,
)
result = handle_function_call("__test_bad_none", {})
parsed = json.loads(result)
assert "_type_warning" in parsed
registry._tools.pop("__test_bad_none", None)
def test_handler_returning_non_json_string_is_wrapped(self):
"""A handler returning a plain string (not JSON) should be wrapped."""
from tools.registry import registry
from model_tools import handle_function_call
import json
registry.register(
name="__test_bad_plain",
toolset="test",
schema={"name": "__test_bad_plain", "description": "test", "parameters": {"type": "object", "properties": {}}},
handler=lambda args, **kw: "just a plain string, not json",
)
result = handle_function_call("__test_bad_plain", {})
parsed = json.loads(result)
assert "output" in parsed
registry._tools.pop("__test_bad_plain", None)
def test_handler_returning_valid_json_passes_through(self):
"""A handler returning valid JSON string passes through unchanged."""
from tools.registry import registry
from model_tools import handle_function_call
import json
registry.register(
name="__test_good",
toolset="test",
schema={"name": "__test_good", "description": "test", "parameters": {"type": "object", "properties": {}}},
handler=lambda args, **kw: json.dumps({"status": "ok", "data": [1, 2, 3]}),
)
result = handle_function_call("__test_good", {})
parsed = json.loads(result)
assert parsed == {"status": "ok", "data": [1, 2, 3]}
registry._tools.pop("__test_good", None)

View File

@@ -144,7 +144,8 @@ class TestMemoryStoreReplace:
def test_replace_no_match(self, store):
store.add("memory", "fact A")
result = store.replace("memory", "nonexistent", "new")
assert result["success"] is False
assert result["success"] is True
assert result["result"] == "no_match"
def test_replace_ambiguous_match(self, store):
store.add("memory", "server A runs nginx")
@@ -177,7 +178,8 @@ class TestMemoryStoreRemove:
def test_remove_no_match(self, store):
result = store.remove("memory", "nonexistent")
assert result["success"] is False
assert result["success"] is True
assert result["result"] == "no_match"
def test_remove_empty_old_text(self, store):
result = store.remove("memory", " ")

View File

@@ -260,8 +260,12 @@ class MemoryStore:
entries = self._entries_for(target)
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
if len(matches) == 0:
return {"success": False, "error": f"No entry matched '{old_text}'."}
if not matches:
return {
"success": True,
"result": "no_match",
"message": f"No entry matched '{old_text}'. The search substring was not found in any existing entry.",
}
if len(matches) > 1:
# If all matches are identical (exact duplicates), operate on the first one
@@ -310,8 +314,12 @@ class MemoryStore:
entries = self._entries_for(target)
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
if len(matches) == 0:
return {"success": False, "error": f"No entry matched '{old_text}'."}
if not matches:
return {
"success": True,
"result": "no_match",
"message": f"No entry matched '{old_text}'. The search substring was not found in any existing entry.",
}
if len(matches) > 1:
# If all matches are identical (exact duplicates), remove the first one
@@ -449,30 +457,30 @@ def memory_tool(
Returns JSON string with results.
"""
if store is None:
return json.dumps({"success": False, "error": "Memory is not available. It may be disabled in config or this environment."}, ensure_ascii=False)
return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False)
if target not in ("memory", "user"):
return json.dumps({"success": False, "error": f"Invalid target '{target}'. Use 'memory' or 'user'."}, ensure_ascii=False)
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
if action == "add":
if not content:
return json.dumps({"success": False, "error": "Content is required for 'add' action."}, ensure_ascii=False)
return tool_error("Content is required for 'add' action.", success=False)
result = store.add(target, content)
elif action == "replace":
if not old_text:
return json.dumps({"success": False, "error": "old_text is required for 'replace' action."}, ensure_ascii=False)
return tool_error("old_text is required for 'replace' action.", success=False)
if not content:
return json.dumps({"success": False, "error": "content is required for 'replace' action."}, ensure_ascii=False)
return tool_error("content is required for 'replace' action.", success=False)
result = store.replace(target, old_text, content)
elif action == "remove":
if not old_text:
return json.dumps({"success": False, "error": "old_text is required for 'remove' action."}, ensure_ascii=False)
return tool_error("old_text is required for 'remove' action.", success=False)
result = store.remove(target, old_text)
else:
return json.dumps({"success": False, "error": f"Unknown action '{action}'. Use: add, replace, remove"}, ensure_ascii=False)
return tool_error(f"Unknown action '{action}'. Use: add, replace, remove", success=False)
return json.dumps(result, ensure_ascii=False)
@@ -539,7 +547,7 @@ MEMORY_SCHEMA = {
# --- Registry ---
from tools.registry import registry
from tools.registry import registry, tool_error
registry.register(
name="memory",