Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Fable 5.1 16b9fe615d test(desktop): read registry copies as UTF-8 so the smart-approval test passes on Windows
read_text() with no encoding decodes with the platform default -- cp1252 on
the Windows runner -- and ui/index.html carries bytes cp1252 cannot map, so
the copies-agree test errored there and only there. Same class of defect as
#3499 / #3424; same one-word fix.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-03 02:03:18 +00:00
MervinPraisonandClaude Fable 5.1 ccfa342288 fix(desktop): make approval_mode "smart" mean something
Settings offered ask / smart ("Ask for risky actions") / never, and _gate()
tested only for "never". "smart" fell through to the same prompt as "ask" on
every gated call -- a safety setting accepted and ignored.

"smart" now waves through _LOW_RISK_TOOLS -- read_file, list_directory,
current_time -- which never leave the machine, and still asks before
fetch_url, the one tool that sends a model-chosen URL to an arbitrary host
(the exfiltration and SSRF surface a "risky actions" gate exists to catch).
The option's label and description in all three registry copies now say
exactly that, and a test pins the copies to each other.

Tests drive _gate() through the real tool functions with the approval
stream stubbed. All four mutations killed: removing the branch, classifying
fetch_url low-risk, waving everything through, and loosening "ask".

Closes #4717.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-03 02:03:18 +00:00
MervinPraisonandClaude Fable 5.1 66a7e89f8f fix(agent): forward max_tokens and top_p on both streaming paths
Both knobs travelled the same route as temperature -- collected, validated,
persisted and echoed by the caller, forwarded into agent.start(**kwargs) --
and were then dropped: the OpenAI streaming request was built from
model / messages / temperature / stream only, and the custom-LLM branch's
explicit argument list to get_response_stream() named temperature and neither
of the other two. temperature arrived, which is what made this a silent drop
rather than a dead override channel.

Present-only on both branches. The custom-LLM path merges with
params.update(override_params), so an explicit None would clobber the
instance-level max_tokens/top_p rather than leave it alone.

Tests stub the transport and assert the kwargs it receives; the stub proxies
the real client wrapper so build_messages() and friends keep working. All
four mutations killed: dropping the forwarding on either branch, and
forwarding None on either branch.

Closes #4715.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-03 02:03:18 +00:00
6 changed files with 189 additions and 6 deletions

No files matched your search

@@ -0,0 +1,90 @@
"""max_tokens and top_p passed to Agent.start(stream=True) must reach the request.
Both knobs travelled the same route as temperature -- collected by the desktop,
validated, persisted, echoed by GET /settings, forwarded into
agent.start(**kwargs) -- and were then dropped: the streaming request was built
from model / messages / temperature / stream only. temperature arrived, which is
what made this a silent drop rather than a dead channel.
The tests stub the transport and assert the kwargs the transport receives.
"""
import os
import pytest
from praisonaiagents import Agent
class _Delta:
content = None
tool_calls = None
class _Chunk:
def __init__(self):
d = _Delta(); d.content = "ok"
self.choices = [type("C", (), {"delta": d, "finish_reason": "stop"})()]
class _RecordingOpenAI:
"""A proxy over the real client wrapper: everything (build_messages, ...)
is delegated; only the transport call is intercepted and recorded."""
def __init__(self, real):
self._real = real
self.calls = []
outer = self
class _Completions:
def create(self, **kw):
outer.calls.append(kw)
return iter([_Chunk()])
class _Chat:
completions = _Completions()
class _Sync:
chat = _Chat()
self.sync_client = _Sync()
def __getattr__(self, name):
return getattr(self._real, name)
@pytest.fixture
def agent(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
a = Agent(name="t", instructions="x", llm="gpt-4o-mini")
from praisonaiagents.llm import get_openai_client
stub = _RecordingOpenAI(get_openai_client(api_key="sk-test"))
a._Agent__openai_client = stub # the lazily-built client, pre-seeded
return a, stub
def _drain(gen):
return "".join(gen)
def test_openai_stream_forwards_max_tokens_and_top_p(agent):
a, stub = agent
_drain(a.start("hi", stream=True, max_tokens=7, top_p=0.5))
assert stub.calls, "the stub transport was never called"
kw = stub.calls[0]
assert kw.get("max_tokens") == 7, kw
assert kw.get("top_p") == 0.5, kw
def test_openai_stream_omits_knobs_that_were_not_given(agent):
"""Present-only: an unset knob must stay OUT of the payload."""
a, stub = agent
_drain(a.start("hi", stream=True))
kw = stub.calls[0]
assert "max_tokens" not in kw and "top_p" not in kw, kw
def test_custom_llm_stream_forwards_knobs_without_clobbering(monkeypatch):
"""The LiteLLM branch merges with params.update(); None must never be sent."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
a = Agent(name="t", instructions="x", llm="openai/gpt-4o-mini")
seen = {}
def fake_stream(**kw):
seen.update(kw); yield "ok"
a.llm_instance.get_response_stream = fake_stream
_drain(a.start("hi", stream=True, max_tokens=9))
assert seen.get("max_tokens") == 9
assert "top_p" not in seen, "top_p was not given and must not be forwarded as None"
+11
View File
@@ -376,6 +376,11 @@ _agents = {}
_approval_lock = threading.Condition()
_approvals = {} # approval_id -> {"call_id","name","args","decision"}
_always_allow = set() # tool names the user granted for this process
# What approval_mode "smart" waves through. Reads of the local filesystem and
# the clock never leave the machine. fetch_url is deliberately absent: it
# sends a model-chosen URL to an arbitrary host, which is the exfiltration
# and SSRF surface a "risky actions" gate exists to catch.
_LOW_RISK_TOOLS = frozenset({"read_file", "list_directory", "current_time"})
APPROVAL_TIMEOUT_S = 300
@@ -523,6 +528,12 @@ def _builtin_tools():
mode = load_settings().get("approval_mode", "ask")
if mode == "never" or name in _always_allow:
return True
if mode == "smart" and name in _LOW_RISK_TOOLS:
# "Ask for risky actions" used to be checked nowhere -- it fell
# through to the same prompt as "ask", so the setting did nothing.
# Local reads stay on the machine; the one tool that sends a
# model-chosen URL out over the network still asks.
return True
if getattr(_tool_events, "emit", None) is None:
# No stream to ask on. Blocking here would hang the turn for the
# full timeout with nobody able to answer, so refuse instead --
@@ -0,0 +1,82 @@
"""approval_mode "smart" must differ from "ask" -- and only where it is safe.
The Settings select offered ask / smart ("Ask for risky actions") / never, and
_gate() checked only for "never". "smart" fell through to the same prompt as
"ask" on every gated call: a safety setting accepted and ignored.
These tests pin the contract by driving _gate() through the real tool
functions, with the approval stream stubbed so a prompt is observable and a
"decision" can be scripted.
"""
import importlib.util
import os
import pathlib
import tempfile
import unittest
os.environ.setdefault("PRAISONAI_DESKTOP_HOME", tempfile.mkdtemp(prefix="praison-smart-"))
os.environ.setdefault("PRAISONAI_KEYCHAIN_SERVICE", "ai.praison.desktop.test.smart")
_spec = importlib.util.spec_from_file_location(
"engine_server_smart", pathlib.Path(__file__).with_name("server.py"))
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
class SmartApproval(unittest.TestCase):
def setUp(self):
self.prompts = []
self._settings = {"approval_mode": "smart"}
self._orig = (server.load_settings, server._emit_now, server._await_decision)
server.load_settings = lambda: dict(server.DEFAULT_SETTINGS, **self._settings)
server._emit_now = lambda kind, payload: self.prompts.append((kind, payload))
server._await_decision = lambda aid: "deny" # any prompt is refused
server._tool_events.emit = lambda *a, **k: None # "a stream exists"
server._always_allow.clear()
self.tools = {t.__name__: t for t in server._builtin_tools()}
def tearDown(self):
server.load_settings, server._emit_now, server._await_decision = self._orig
if hasattr(server._tool_events, "emit"):
del server._tool_events.emit
def asked(self, name):
return any(k == "approval_request" and p["name"] == name for k, p in self.prompts)
def test_smart_lets_a_local_read_through_without_asking(self):
out = self.tools["list_directory"](".")
self.assertFalse(self.asked("list_directory"), "smart prompted for a local read")
self.assertNotIn("declined", out.lower())
def test_smart_still_asks_before_a_network_fetch(self):
self.tools["fetch_url"]("http://127.0.0.1:9/never")
self.assertTrue(self.asked("fetch_url"),
"smart let a model-chosen URL go out without asking")
def test_ask_still_asks_for_everything_gated(self):
"""The fix must not loosen 'ask'."""
self._settings["approval_mode"] = "ask"
self.tools["list_directory"](".")
self.assertTrue(self.asked("list_directory"))
def test_never_asks_for_nothing(self):
self._settings["approval_mode"] = "never"
self.tools["fetch_url"]("http://127.0.0.1:9/never")
self.assertFalse(self.asked("fetch_url"))
def test_fetch_url_is_not_low_risk(self):
"""Pin the classification itself, so a future edit has to mean it."""
self.assertNotIn("fetch_url", server._LOW_RISK_TOOLS)
self.assertIn("read_file", server._LOW_RISK_TOOLS)
def test_every_registry_copy_describes_smart_the_same_way(self):
root = pathlib.Path(__file__).resolve().parents[1]
texts = [(root / f).read_text(encoding="utf-8") for f in
("ui/index.html", "ui/settings-registry.js", "frontend/src/settings-registry.js")]
for t in texts:
self.assertIn('value: "smart", label: "Ask only for network fetches"', t)
self.assertNotIn("Ask for risky actions", t, "stale label survived in a copy")
if __name__ == "__main__":
unittest.main()
@@ -124,11 +124,11 @@ const SETTINGS = [
// ---- Safety -------------------------------------------------------------
{ key: "approval_mode", section: "safety", label: "Tool approval",
description: "How tool calls that touch your files are approved before running.",
description: "How tool calls are approved. Local file reads are low risk; fetching a URL sends data out and is asked for in all but Never.",
keywords: ["permissions", "confirm", "yolo", "sandbox"],
control: { kind: "select", options: [
{ value: "ask", label: "Ask every time" },
{ value: "smart", label: "Ask for risky actions" },
{ value: "smart", label: "Ask only for network fetches" },
{ value: "never", label: "Never ask" }]},
default: "ask",
confirm: { when: (_p, n) => n === "never",
+2 -2
View File
@@ -753,11 +753,11 @@ const SETTINGS = [
// ---- Safety -------------------------------------------------------------
{ key: "approval_mode", section: "safety", label: "Tool approval",
description: "How tool calls that touch your files are approved before running.",
description: "How tool calls are approved. Local file reads are low risk; fetching a URL sends data out and is asked for in all but Never.",
keywords: ["permissions", "confirm", "yolo", "sandbox"],
control: { kind: "select", options: [
{ value: "ask", label: "Ask every time" },
{ value: "smart", label: "Ask for risky actions" },
{ value: "smart", label: "Ask only for network fetches" },
{ value: "never", label: "Never ask" }]},
default: "ask",
confirm: { when: (_p, n) => n === "never",
@@ -124,11 +124,11 @@ const SETTINGS = [
// ---- Safety -------------------------------------------------------------
{ key: "approval_mode", section: "safety", label: "Tool approval",
description: "How tool calls that touch your files are approved before running.",
description: "How tool calls are approved. Local file reads are low risk; fetching a URL sends data out and is asked for in all but Never.",
keywords: ["permissions", "confirm", "yolo", "sandbox"],
control: { kind: "select", options: [
{ value: "ask", label: "Ask every time" },
{ value: "smart", label: "Ask for risky actions" },
{ value: "smart", label: "Ask only for network fetches" },
{ value: "never", label: "Never ask" }]},
default: "ask",
confirm: { when: (_p, n) => n === "never",