mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools+cli: split fetch into remote fetch and fetch_local
This commit is contained in:
@@ -544,7 +544,7 @@ async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
|
||||
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (200)" in out
|
||||
assert "[tool]" not in out
|
||||
@@ -555,7 +555,7 @@ async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) ->
|
||||
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
|
||||
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com/nope"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com/nope (404)" in out
|
||||
|
||||
@@ -565,17 +565,37 @@ async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> Non
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
|
||||
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (200)" in out
|
||||
|
||||
|
||||
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
|
||||
_aiter(
|
||||
[_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result("error: fetch_local failed: boom")]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
|
||||
assert "* Fetch GET https://example.com (error: fetch_local failed: boom)" in out
|
||||
|
||||
|
||||
async def test_pretty_remote_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = "title=Example article\nurl=https://example.com\nchars=1234\ntruncated=false\n--- content ---\n..."
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch https://example.com (1234 chars)" in out
|
||||
|
||||
|
||||
async def test_pretty_fetch_reads_a_legacy_local_row(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
# Rows written before the local tool was renamed carry the local status block.
|
||||
result = (
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
|
||||
"body_kind=text\nbytes=12\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nhi\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch https://example.com (200)" in out
|
||||
|
||||
|
||||
async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
+54
-7
@@ -1,12 +1,12 @@
|
||||
"""DeepSeek's Search API client (``POST /v1/tools/search``)."""
|
||||
"""DeepSeek's Search and Fetch APIs (``/v1/tools/search``, ``/v1/tools/fetch``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.deepseek.search import (
|
||||
DeepseekSearchClient,
|
||||
from plyngent.lmproto.deepseek.webtools import (
|
||||
DeepseekWebToolsClient,
|
||||
SearchRequest,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
||||
@@ -23,8 +23,8 @@ def _body(*results: dict[str, object]) -> bytes:
|
||||
return msgspec.json.encode({"results": list(results)})
|
||||
|
||||
|
||||
def _client() -> DeepseekSearchClient:
|
||||
return DeepseekSearchClient(
|
||||
def _client() -> DeepseekWebToolsClient:
|
||||
return DeepseekWebToolsClient(
|
||||
OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com"),
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ async def test_the_endpoint_error_detail_is_raised(monkeypatch: pytest.MonkeyPat
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retryable_status_is_tried_again(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.search.client.RETRY_DELAYS", (0.0,))
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
|
||||
statuses = [429, 200]
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
@@ -181,7 +181,7 @@ async def test_a_retryable_status_is_tried_again(monkeypatch: pytest.MonkeyPatch
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_balance_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.search.client.RETRY_DELAYS", (0.0,))
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
|
||||
calls: list[int] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
@@ -197,3 +197,50 @@ async def test_a_balance_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -
|
||||
def test_the_request_model_omits_what_was_not_set() -> None:
|
||||
encoded = msgspec.json.encode(SearchRequest(query="q"))
|
||||
assert msgspec.json.decode(encoded, type=dict[str, object]) == {"query": "q"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_posts_the_url_and_returns_the_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(200, msgspec.json.encode({"title": "Example article", "content": "# Article\n\nBody."}))
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
page = await client.fetch("https://example.com/article")
|
||||
|
||||
assert page.title == "Example article"
|
||||
assert page.content == "# Article\n\nBody."
|
||||
assert sent["path"] == "/v1/tools/fetch"
|
||||
assert _decode_request(sent["data"]) == {"url": "https://example.com/article"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_rejects_a_url_the_endpoint_would_refuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
calls: list[object] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
calls.append(path)
|
||||
return _Resp(200, b"{}")
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
for url in ("file:///etc/passwd", "https://user:secret@example.com/", "https:///no-host", ""):
|
||||
with pytest.raises(ValueError):
|
||||
_ = await client.fetch(url)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_surfaces_a_security_policy_rejection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(403, b'{"error": {"message": "target rejected by security policy"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="security policy"):
|
||||
_ = await client.fetch("https://example.com/blocked")
|
||||
@@ -74,7 +74,7 @@ def test_read_only_classification() -> None:
|
||||
"vcs_diff",
|
||||
"vcs_log",
|
||||
"vcs_branch",
|
||||
"fetch",
|
||||
"fetch_local",
|
||||
"skill_list",
|
||||
"skill_read",
|
||||
"skill_search",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for tools.net.fetch (policy, UA, methods, private grants)."""
|
||||
"""Tests for tools.net.fetch_local (policy, UA, methods, private grants)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
from plyngent.tools.context import read_only_context
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.net import fetch, grant_private_host
|
||||
from plyngent.tools.net import fetch_local, grant_private_host
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_USER_AGENT,
|
||||
FetchPolicyError,
|
||||
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Minimal echo server for fetch tests."""
|
||||
"""Minimal echo server for fetch_local tests."""
|
||||
|
||||
@override
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
@@ -171,10 +171,10 @@ def test_soft_confirm_reason_matrix() -> None:
|
||||
|
||||
|
||||
def test_classify_danger_fetch() -> None:
|
||||
assert classify_danger("fetch", {"url": "https://example.com/", "method": "GET"}) is None
|
||||
reason = classify_danger("fetch", {"url": "http://example.com/", "method": "GET"})
|
||||
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
|
||||
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
|
||||
assert reason is not None and "cleartext" in reason
|
||||
reason2 = classify_danger("fetch", {"url": "https://example.com/api", "method": "DELETE"})
|
||||
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
|
||||
assert reason2 is not None and "DELETE" in reason2
|
||||
|
||||
|
||||
@@ -184,36 +184,36 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
|
||||
# Grant loopback for this process/instance (fixture binds InstanceState).
|
||||
grant_private_host("127.0.0.1", int(base.rsplit(":", 1)[1]))
|
||||
|
||||
out = await call_async(fetch, f"{base}/hello")
|
||||
out = await call_async(fetch_local, f"{base}/hello")
|
||||
assert "status=200" in out
|
||||
assert "GET /hello" in out
|
||||
assert "security=cleartext-http" in out
|
||||
|
||||
out_post = await call_async(fetch, f"{base}/echo", method="POST", body="hi")
|
||||
out_post = await call_async(fetch_local, f"{base}/echo", method="POST", body="hi")
|
||||
assert "status=201" in out_post
|
||||
assert "POST:hi" in out_post
|
||||
|
||||
out_put = await call_async(fetch, f"{base}/echo", method="PUT", body="x")
|
||||
out_put = await call_async(fetch_local, f"{base}/echo", method="PUT", body="x")
|
||||
assert "PUT:x" in out_put
|
||||
|
||||
out_del = await call_async(fetch, f"{base}/x", method="DELETE")
|
||||
out_del = await call_async(fetch_local, f"{base}/x", method="DELETE")
|
||||
assert "status=204" in out_del
|
||||
|
||||
|
||||
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
||||
"""In a read-only context fetch only allows GET; outside it POST works."""
|
||||
"""In a read-only context fetch_local only allows GET; outside it POST works."""
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
with read_only_context():
|
||||
out = await call_async(fetch, f"{http_server}/hello")
|
||||
out = await call_async(fetch_local, f"{http_server}/hello")
|
||||
assert "status=200" in out # GET is fine in a read-only context
|
||||
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||
blocked = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
|
||||
assert blocked.startswith("error:")
|
||||
assert "GET" in blocked
|
||||
|
||||
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||
out_post = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
|
||||
assert "POST:hi" in out_post
|
||||
|
||||
|
||||
@@ -222,18 +222,18 @@ async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/ua", user_agent="AgentUA/9")
|
||||
out = await call_async(fetch_local, f"{http_server}/ua", user_agent="AgentUA/9")
|
||||
assert "AgentUA/9" in out
|
||||
|
||||
out2 = await call_async(
|
||||
fetch,
|
||||
fetch_local,
|
||||
f"{http_server}/ua",
|
||||
headers={"User-Agent": "HeaderUA/1"},
|
||||
)
|
||||
assert "HeaderUA/1" in out2
|
||||
|
||||
out3 = await call_async(
|
||||
fetch,
|
||||
fetch_local,
|
||||
f"{http_server}/ua",
|
||||
headers={"User-Agent": "HeaderUA/1"},
|
||||
user_agent="ArgUA/2",
|
||||
@@ -247,11 +247,11 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/big", max_bytes=100)
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_bytes=100)
|
||||
assert "truncated=true" in out
|
||||
assert "bytes=100" in out
|
||||
|
||||
out_bin = await call_async(fetch, f"{http_server}/bin")
|
||||
out_bin = await call_async(fetch_local, f"{http_server}/bin")
|
||||
assert "body_kind=binary" in out_bin
|
||||
assert "binary body omitted" in out_bin
|
||||
|
||||
@@ -261,7 +261,7 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_chars=1000)
|
||||
assert "truncated=true" in out
|
||||
body = out.split("--- body ---", 1)[-1]
|
||||
assert "truncate_token=" in body
|
||||
@@ -281,7 +281,7 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
|
||||
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
|
||||
body = out.split("--- body ---", 1)[-1]
|
||||
assert body.strip() == "x" * 100
|
||||
assert "truncate_token=" not in out
|
||||
@@ -290,21 +290,21 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
|
||||
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
|
||||
del workspace
|
||||
# No grant, no policy hook → hard deny (even though server is up).
|
||||
out = await call_async(fetch, f"{http_server}/hello")
|
||||
out = await call_async(fetch_local, f"{http_server}/hello")
|
||||
assert out.startswith("error:")
|
||||
assert "private" in out or "loopback" in out
|
||||
|
||||
|
||||
async def test_fetch_forbidden_metadata(workspace: Path) -> None:
|
||||
del workspace
|
||||
out = await call_async(fetch, "http://169.254.169.254/latest/meta-data/")
|
||||
out = await call_async(fetch_local, "http://169.254.169.254/latest/meta-data/")
|
||||
assert out.startswith("error:")
|
||||
assert "forbidden" in out
|
||||
|
||||
|
||||
async def test_fetch_bad_scheme(workspace: Path) -> None:
|
||||
del workspace
|
||||
out = await call_async(fetch, "file:///etc/passwd")
|
||||
out = await call_async(fetch_local, "file:///etc/passwd")
|
||||
assert out.startswith("error:")
|
||||
assert "scheme" in out
|
||||
|
||||
@@ -313,6 +313,6 @@ async def test_fetch_redirect_loop_capped(workspace: Path, http_server: str) ->
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
out = await call_async(fetch, f"{http_server}/redirect-loop", max_redirects=3)
|
||||
out = await call_async(fetch_local, f"{http_server}/redirect-loop", max_redirects=3)
|
||||
assert out.startswith("error:")
|
||||
assert "redirect" in out.lower()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The remote ``fetch`` tool (DeepSeek's Fetch API, Markdown pages)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.webtools import FetchResponse
|
||||
from plyngent.tools.net.fetch_remote import FETCH_TOOL_NAME, build_fetch_tool
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Stands in for DeepseekWebToolsClient.fetch."""
|
||||
|
||||
def __init__(self, *, page: FetchResponse | None = None, error: str | None = None) -> None:
|
||||
self.page = page or FetchResponse(title="T", content="body")
|
||||
self.error = error
|
||||
self.urls: list[str] = []
|
||||
|
||||
async def fetch(self, url: str) -> FetchResponse:
|
||||
self.urls.append(url)
|
||||
if self.error is not None:
|
||||
raise RuntimeError(self.error)
|
||||
return self.page
|
||||
|
||||
|
||||
def _tool(client: _Client, *, max_chars: int = 32_000):
|
||||
return build_fetch_tool(client, default_max_chars=max_chars)
|
||||
|
||||
|
||||
def test_definition_shape() -> None:
|
||||
definition = _tool(_Client())
|
||||
assert definition.name == FETCH_TOOL_NAME == "fetch"
|
||||
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
|
||||
properties = definition.parameters["properties"]
|
||||
assert set(properties) == {"url", "offset", "max_chars"}
|
||||
assert definition.parameters["required"] == ["url"]
|
||||
assert "fetch_local" in definition.description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_renders_title_url_and_content() -> None:
|
||||
client = _Client(page=FetchResponse(title=" Example article ", content="# Heading\n\nBody."))
|
||||
result = await _tool(client).handler(url="https://example.com/article")
|
||||
|
||||
assert client.urls == ["https://example.com/article"]
|
||||
assert result.splitlines() == [
|
||||
"title=Example article",
|
||||
"url=https://example.com/article",
|
||||
"chars=16",
|
||||
"truncated=false",
|
||||
"--- content ---",
|
||||
"# Heading",
|
||||
"",
|
||||
"Body.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
|
||||
client = _Client(page=FetchResponse(title="", content="0123456789"))
|
||||
result = await _tool(client, max_chars=3).handler(url="https://example.com/", offset=5)
|
||||
|
||||
assert "title=(untitled)" in result
|
||||
assert "--- content ---\n567" in result
|
||||
assert "truncated=true" in result
|
||||
assert "truncate_token=" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_reports_a_client_error() -> None:
|
||||
result = await _tool(_Client(error="HTTP 404: no usable content")).handler(url="https://example.com/x")
|
||||
assert result == "error: fetch failed: HTTP 404: no usable content"
|
||||
@@ -86,9 +86,9 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
|
||||
|
||||
async def test_get_truncated_serves_a_fetch_body_without_refetching(monkeypatch) -> None:
|
||||
"""A fetch body's token reads the stored remainder; the request is not repeated."""
|
||||
from plyngent.tools.net.fetch import format_fetch_result
|
||||
from plyngent.tools.net.fetch_local import format_fetch_result
|
||||
|
||||
fetch_module = importlib.import_module("plyngent.tools.net.fetch")
|
||||
fetch_module = importlib.import_module("plyngent.tools.net.fetch_local")
|
||||
|
||||
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
|
||||
msg = "resuming a fetch body must not re-run the request"
|
||||
|
||||
Reference in New Issue
Block a user