core/tools+cli: split fetch into remote fetch and fetch_local

This commit is contained in:
2026-10-17 17:06:43 +08:00
parent 37e130a7dc
commit cfe36b024b
28 changed files with 465 additions and 125 deletions
+25 -5
View File
@@ -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:
@@ -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")
+1 -1
View File
@@ -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()
+73
View File
@@ -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"
+2 -2
View File
@@ -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"