mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b9fe615d | ||
|
|
ccfa342288 | ||
|
|
66a7e89f8f |
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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user