Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions docs/designs/web_fetch_tool.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions docs/generated-app-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,19 @@
],
"default": null,
"description": "Built-in DIAL files tools (list / read / search / write / edit / delete / copy / move)."
},
"web_fetch": {
"anyOf": [
{
"$ref": "#/$defs/WebFetchConfig"
},
{
"type": "null"
}
],
"default": null,
"description": "Built-in internal_web_fetch tool: fetch an external resource and return it inline, or persist it to the workspace when a save_path is given (binary / oversized content must be saved rather than read inline).",
"x-preview": true
}
},
"title": "Features",
Expand Down Expand Up @@ -3245,6 +3258,25 @@
],
"title": "UserDefinedContextConfig",
"type": "object"
},
"WebFetchConfig": {
"description": "Per-app config for the built-in ``internal_web_fetch`` tool.\n\nToggled by the explicit ``enabled`` flag. ``max_inline_size`` caps how much\ndecoded text the tool returns inline; its default is drawn from the same env\nsetting that governs the offload threshold's default\n(``TOOL_CALL_RESULT_OFFLOAD__SIZE_THRESHOLD``), so out of the box the inline\ncap and the global offload threshold are equal — anything returned inline is\nbelow the offloader's trigger. See docs/designs/web_fetch_tool.md (Component\n4a) for the cases where an operator intentionally decouples the two.",
"properties": {
"enabled": {
"default": false,
"description": "Whether to expose the internal_web_fetch tool to the LLM.",
"title": "Enabled",
"type": "boolean"
},
"max_inline_size": {
"description": "Byte cap on the decoded text internal_web_fetch returns inline. Larger (or binary) resources are rejected with guidance to re-call with a save_path. Defaults to the TOOL_CALL_RESULT_OFFLOAD__SIZE_THRESHOLD env var so the inline cap matches the global offload threshold by default.",
"exclusiveMinimum": 0,
"title": "Max Inline Size",
"type": "integer"
}
},
"title": "WebFetchConfig",
"type": "object"
}
},
"properties": {
Expand Down
17 changes: 17 additions & 0 deletions docs/generated-internal-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,5 +351,22 @@
"description": "IANA timezone name (e.g. 'Asia/Tokyo'). Defaults to UTC."
}
}
},
{
"name": "internal_web_fetch",
"description": "Fetch a resource from an external http(s) URL (e.g. a README, a source file, a documentation page). Without save_path it returns the text inline in a single call — text only: binary content (images, PDFs, archives) or a resource too large to fit inline is rejected, re-call with a save_path instead. With save_path it persists the resource (any content type) at that workspace-relative path under the agent home and returns the saved path (+ a short preview for text), so you can then read it with internal_file_read_lines / internal_file_search. DIAL file paths (files/...) are not fetched here; read them with the file tools.",
"properties": {
"url": {
"type": "string",
"description": "The external http(s) URL to fetch."
},
"save_path": {
"type": "string",
"description": "Optional workspace-relative destination (e.g. 'data.py' or 'docs/readme.md'). Omit to read the content inline; provide it to save the resource under the agent home and get back the saved path. Must not be an absolute 'files/...' URL."
}
},
"required": [
"url"
]
}
]
2 changes: 2 additions & 0 deletions src/quickapp/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from quickapp.skills.skills_module import SkillsModule
from quickapp.starters.starters_module import StartersModule
from quickapp.timestamp_tooling.timestamp_module import TimestampModule
from quickapp.web_tooling.web_tooling_module import WebToolingModule


class AppFactory:
Expand Down Expand Up @@ -57,6 +58,7 @@ def build_di_modules() -> list[Module]:
TimestampModule(),
AgentHooksModule(),
DialFilesToolingModule(),
WebToolingModule(),
]
if FeatureSettings().enable_preview_features:
logging.getLogger(__name__).info(
Expand Down
1 change: 1 addition & 0 deletions src/quickapp/common/tool_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
INTERNAL_SKILLS_READ_SKILL_TOOL_NAME = "internal_skills_read_skill"
INTERNAL_TIMEAWARENESS_CURRENT_TIMESTAMP_TOOL_NAME = "internal_timeawareness_current_timestamp"
INTERNAL_CODE_EXECUTION_PYTHON_INTERPRETER_TOOL_NAME = "internal_code_execution_python_interpreter"
INTERNAL_WEB_FETCH_TOOL_NAME = "internal_web_fetch"

# DIAL files tools — all share the ``internal_file_`` prefix.
INTERNAL_FILE_TOOL_NAME_PREFIX = "internal_file_"
Expand Down
9 changes: 9 additions & 0 deletions src/quickapp/config/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from quickapp.config.starters import ConversationStartersConfig
from quickapp.config.timestamp import TimestampConfig, ToolCallTimestampConfig
from quickapp.config.toolsets.toolset import ToolSet
from quickapp.config.web_fetch import WebFetchConfig

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -190,6 +191,14 @@ class Features(BaseModel):
default=None,
description="Built-in DIAL files tools (list / read / search / write / edit / delete / copy / move).",
)
web_fetch: WebFetchConfig | None = PreviewField( # type: ignore[assignment]
default=None,
description=(
"Built-in internal_web_fetch tool: fetch an external resource and return "
"it inline, or persist it to the workspace when a save_path is given "
"(binary / oversized content must be saved rather than read inline)."
),
)


class ToolDefaults(BaseModel):
Expand Down
32 changes: 32 additions & 0 deletions src/quickapp/config/web_fetch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field

from quickapp.config.dial_files import ToolCallResultOffloadSettings


class WebFetchConfig(BaseModel):
"""Per-app config for the built-in ``internal_web_fetch`` tool.

Toggled by the explicit ``enabled`` flag. ``max_inline_size`` caps how much
decoded text the tool returns inline; its default is drawn from the same env
setting that governs the offload threshold's default
(``TOOL_CALL_RESULT_OFFLOAD__SIZE_THRESHOLD``), so out of the box the inline
cap and the global offload threshold are equal — anything returned inline is
below the offloader's trigger. See docs/designs/web_fetch_tool.md (Component
4a) for the cases where an operator intentionally decouples the two.
"""

enabled: bool = Field(
default=False,
description="Whether to expose the internal_web_fetch tool to the LLM.",
)
max_inline_size: int = Field(
default_factory=lambda: ToolCallResultOffloadSettings().size_threshold,
gt=0,
description=(
"Byte cap on the decoded text internal_web_fetch returns inline. "
"Larger (or binary) resources are rejected with guidance to re-call "
"with a save_path. Defaults to the "
"TOOL_CALL_RESULT_OFFLOAD__SIZE_THRESHOLD env var so the inline cap "
"matches the global offload threshold by default."
),
)
43 changes: 41 additions & 2 deletions src/quickapp/dial_core_services/dial_file_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ async def write_file(
self.invalidate_cache(url)
return uploaded_url

async def write_bytes(
self,
url: str,
content: bytes,
*,
content_type: str = "application/octet-stream",
overwrite: bool = False,
) -> str:
"""Create or overwrite a file from raw bytes (binary-safe).

The bytes counterpart of :meth:`write_file` — used for content that is
not UTF-8 text (downloaded binaries). Shares the same ETag semantics:
``overwrite=False`` uploads with ``If-None-Match: *`` (create only,
raising ``EtagMismatchError`` if the file exists); ``overwrite=True``
uploads unconditionally. Cache is invalidated on success.
"""
if overwrite:
uploaded_url = await self._upload_bytes(url, content, content_type)
else:
uploaded_url = await self._upload_bytes(url, content, content_type, if_none_match="*")
self.invalidate_cache(url)
return uploaded_url

async def _upload_text(
self,
url: str,
Expand All @@ -127,11 +150,27 @@ async def _upload_text(
if_none_match: Literal["*"] | None = None,
if_match: str | None = None,
) -> str:
encoded = content.encode("utf-8")
return await self._upload_bytes(
url,
content.encode("utf-8"),
content_type,
if_none_match=if_none_match,
if_match=if_match,
)

async def _upload_bytes(
self,
url: str,
content: bytes,
content_type: str,
*,
if_none_match: Literal["*"] | None = None,
if_match: str | None = None,
) -> str:
filename = url.split("/")[-1]
metadata = await self.__dial_client.files.upload(
url=url,
file=(filename, encoded, content_type),
file=(filename, content, content_type),
etag_if_none_match=if_none_match,
etag_if_match=if_match,
)
Expand Down
4 changes: 2 additions & 2 deletions src/quickapp/dial_files_tooling/_base_file_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
from quickapp.config.dial_files import DialFilesConfig
from quickapp.config.tools.internal import InternalTool
from quickapp.dial_core_services.dial_file_service import DialFileService, FolderEntry
from quickapp.dial_files_tooling._home_path_resolver import _HomePathResolver
from quickapp.dial_files_tooling._stage_wrapper import _FileStageWrapper
from quickapp.dial_files_tooling._utils import is_root_reference
from quickapp.shared.home_path.home_path_resolver import HomePathResolver

logger = logging.getLogger(__name__)

Expand All @@ -36,7 +36,7 @@ def __init__(
perf_timer: PerformanceTimer,
dial_file_service: DialFileService,
dial_files_config: DialFilesConfig,
home_resolver: _HomePathResolver,
home_resolver: HomePathResolver,
stage_display_level: StageDisplayLevel = StageDisplayLevel.INFO,
argument_transformers: list[ToolArgumentTransformer] | None = None,
**kwargs: Any,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from injector import inject

from quickapp.common.abstract.base_tool_argument_transformer import ToolArgumentTransformer
from quickapp.dial_files_tooling._home_path_resolver import _HomePathResolver
from quickapp.shared.home_path.home_path_resolver import HomePathResolver

logger = logging.getLogger(__name__)

Expand All @@ -27,7 +27,7 @@ class _AppdataHomePathTransformer(ToolArgumentTransformer):
resolved to a ``files/...`` URL is relativized too.
"""

def __init__(self, home_resolver: _HomePathResolver) -> None:
def __init__(self, home_resolver: HomePathResolver) -> None:
self._home_resolver = home_resolver

async def transform(self, kwargs: dict[str, Any]) -> dict[str, Any]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from quickapp.dial_files_tooling._delete_file_tool import _DeleteFileTool
from quickapp.dial_files_tooling._edit_file_tool import _EditFileTool
from quickapp.dial_files_tooling._find_files_tool import _FindFilesTool
from quickapp.dial_files_tooling._home_path_resolver import _HomePathResolver
from quickapp.dial_files_tooling._list_files_tool import _ListFilesTool
from quickapp.dial_files_tooling._move_file_tool import _MoveFileTool
from quickapp.dial_files_tooling._offload_config import ResolvedOffloadConfig
Expand Down Expand Up @@ -49,9 +48,7 @@ class DialFilesToolingModule(Module):

def configure(self, binder: Binder) -> None:
binder.bind(_FileStageWrapper, to=_FileStageWrapper, scope=request_scope)
# Request-scoped so the agent home dir is resolved once per request and shared
# between the file tools and the path-argument transformer.
binder.bind(_HomePathResolver, to=_HomePathResolver, scope=request_scope)
# HomePathResolver is bound by shared HomePathModule (shared_module).
binder.bind(
_AppdataHomePathTransformer, to=_AppdataHomePathTransformer, scope=request_scope
)
Expand Down
2 changes: 2 additions & 0 deletions src/quickapp/shared/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from quickapp.shared.config_resolvers.config_resolvers_module import ConfigResolversModule
from quickapp.shared.external_fetch.external_fetch_module import ExternalFetchModule
from quickapp.shared.home_path.home_path_module import HomePathModule

# Cross-cutting utility DI modules. ``app_factory`` splices this array into its module
# list, so future utility modules join by appending here rather than being registered
# individually.
shared_module: list[Module] = [
ConfigResolversModule(),
ExternalFetchModule(),
HomePathModule(),
]
2 changes: 2 additions & 0 deletions src/quickapp/shared/external_fetch/external_fetch_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ExternalUrlFetchPolicyResolver,
)
from quickapp.shared.external_fetch.external_url_fetcher import ExternalUrlFetcher
from quickapp.shared.external_fetch.web_content_fetcher import WebContentFetcher


class ExternalFetchModule(Module):
Expand All @@ -24,3 +25,4 @@ def configure(self, binder: Binder) -> None:
scope=request_scope,
)
binder.bind(ExternalUrlFetcher, to=ExternalUrlFetcher, scope=request_scope)
binder.bind(WebContentFetcher, to=WebContentFetcher, scope=request_scope)
Loading
Loading