Skip to content

Commit 2351cd0

Browse files
authored
Merge pull request #210 from VectorlyApp/agent-tool-decorator
Add `LLMExclude` field annotation for agent tool results
2 parents 130e9fb + d1e802d commit 2351cd0

7 files changed

Lines changed: 899 additions & 113 deletions

File tree

bluebox/agents/abstract_agent.py

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,24 +61,47 @@
6161
get_workaround_for_error,
6262
)
6363
from bluebox.utils.data_utils import format_bytes
64+
from bluebox.utils.llm_serialization import serialize_tool_result, strip_llm_excluded
6465
from bluebox.utils.llm_utils import token_optimized as token_optimized_decorator
6566
from bluebox.utils.logger import get_logger
6667

6768
logger = get_logger(name=__name__)
6869

6970

71+
# Keep persisted tool previews small so iterative runs don't bloat context
72+
PERSISTED_TOOL_PREVIEW_MAX_CHARS = 800
73+
74+
7075
class ToolResultPersistMode(StrEnum):
76+
"""
77+
Policy controlling when a tool result is persisted to the workspace.
78+
79+
Persistence saves the full result as a raw artifact and returns a
80+
compact preview to the LLM, keeping context usage in check for
81+
large payloads.
82+
83+
Attributes:
84+
NEVER: Never persist; the full result is returned inline.
85+
ALWAYS: Always persist, regardless of size.
86+
OVERFLOW: Persist only when the serialized result exceeds the
87+
tool's ``max_characters`` threshold.
88+
"""
7189
NEVER = "never"
7290
ALWAYS = "always"
7391
OVERFLOW = "overflow"
7492

7593

76-
# Keep persisted tool previews small so iterative runs don't blow context.
77-
PERSISTED_TOOL_PREVIEW_MAX_CHARS = 800
78-
79-
8094
class AgentExecutionMode(StrEnum):
81-
"""Execution mode for agent loops."""
95+
"""
96+
Execution mode for agent loops.
97+
98+
Attributes:
99+
CONVERSATIONAL: Interactive mode where the agent responds to user
100+
messages one at a time via :meth:`process_new_message`.
101+
AUTONOMOUS: Self-directed mode where the agent runs a tool-driven
102+
loop until it calls a finalize tool or hits the iteration cap.
103+
See :meth:`AbstractAgent.run_autonomous`.
104+
"""
82105
CONVERSATIONAL = "conversational"
83106
AUTONOMOUS = "autonomous"
84107

@@ -108,36 +131,75 @@ class variable. Orchestrator agents use these cards to discover subagent
108131

109132
@dataclass(frozen=True)
110133
class _ToolMeta:
111-
"""Metadata attached to a handler method by @agent_tool."""
112-
name: str # tool name registered with the LLM client
113-
description: str # tool description shown to the LLM
114-
parameters: dict[str, Any] # JSON Schema for tool parameters
115-
availability: bool | Callable[..., bool] # whether the tool should be registered right now
134+
"""
135+
Metadata attached to a handler method by :func:`agent_tool`.
136+
137+
Instances are stored on the decorated method as ``method._tool_meta``
138+
and collected at class-definition time by
139+
:meth:`AbstractAgent._collect_tools`.
140+
141+
Attributes:
142+
name: Tool name registered with the LLM client (derived from the
143+
method name by stripping leading underscores).
144+
description: Human-readable description shown to the LLM.
145+
parameters: JSON Schema ``object`` describing accepted parameters.
146+
availability: Static boolean or a callable ``(self) -> bool``
147+
evaluated before each LLM call to gate tool registration.
148+
persist: Result-persistence policy. See :class:`ToolResultPersistMode`.
149+
max_characters: Character threshold used by
150+
:attr:`ToolResultPersistMode.OVERFLOW` to decide when to
151+
persist a result to the workspace.
152+
token_optimized: If ``True``, the tool result is encoded with
153+
the ``token_optimized`` decorator for reduced token usage.
154+
"""
155+
name: str
156+
description: str
157+
parameters: dict[str, Any]
158+
availability: bool | Callable[..., bool]
116159
persist: ToolResultPersistMode = ToolResultPersistMode.NEVER
117160
max_characters: int = 10_000
118161
token_optimized: bool = False
119162

120163

121-
def _serialize_tool_result(tool_result: Any) -> tuple[str, str]:
122-
try:
123-
return json.dumps(tool_result, ensure_ascii=False, default=str, indent=2), "json"
124-
except (TypeError, ValueError):
125-
return str(tool_result), "text"
164+
def _normalize_file_scope(scope: str) -> str:
165+
"""
166+
Normalize and validate a file-tool scope string. Strips whitespace, lowercases,
167+
and ensures the value is one of the accepted scope literals.
126168
169+
Args:
170+
scope: Raw scope value from a tool call (e.g. ``"Workspace"``).
127171
128-
def _normalize_file_scope(scope: str) -> str:
129-
"""Normalize and validate file tool scope."""
172+
Returns:
173+
The normalized scope (``"workspace"`` or ``"docs"``).
174+
175+
Raises:
176+
ValueError: If *scope* is not a recognized value.
177+
"""
130178
normalized_scope = scope.strip().lower()
131179
if normalized_scope not in {"workspace", "docs"}:
132180
raise ValueError("scope must be 'workspace' or 'docs'")
133181
return normalized_scope
134182

135183

136184
def _parse_search_terms(query: str) -> list[str]:
137-
"""Split query text into distinct terms for terms-mode search."""
185+
"""
186+
Split a query string into unique, order-preserving search terms.
187+
188+
Tokens are split on commas and whitespace. Empty tokens and
189+
duplicates are discarded while preserving first-occurrence order.
190+
191+
Args:
192+
query: Free-text search query (e.g. ``"foo, bar baz"``).
193+
194+
Returns:
195+
Deduplicated list of non-empty terms in original order.
196+
"""
138197
seen: set[str] = set()
139198
terms: list[str] = []
140-
for token in re.split(r"[,\s]+", query):
199+
for token in re.split(
200+
pattern=r"[,\s]+",
201+
string=query
202+
):
141203
term = token.strip()
142204
if term and term not in seen:
143205
seen.add(term)
@@ -551,7 +613,7 @@ def _maybe_persist_tool_result(
551613
if persist_mode == ToolResultPersistMode.NEVER:
552614
return tool_result
553615

554-
serialized, content_type = _serialize_tool_result(tool_result)
616+
serialized, content_type = serialize_tool_result(tool_result)
555617
char_count = len(serialized)
556618

557619
if persist_mode == ToolResultPersistMode.OVERFLOW and char_count <= tool_meta.max_characters:
@@ -1080,6 +1142,7 @@ def _execute_tool(self, tool_name: str, tool_arguments: dict[str, Any]) -> dict[
10801142
logger.debug("Executing tool %s with arguments: %s", tool_name, tool_arguments)
10811143
# handler is unbound (from cls, not self) so pass self explicitly
10821144
raw_result = handler(self, **validated_arguments)
1145+
raw_result = strip_llm_excluded(raw_result) # strip LLMExclude-annotated fields from any Pydantic models
10831146
result_for_llm = self._maybe_persist_tool_result(
10841147
tool_name=tool_name,
10851148
tool_meta=tool_meta,

bluebox/agents/specialists/interaction_specialist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class InteractionSpecialist(AbstractAgent):
5656
"structural context (forms, inputs, buttons, links)."
5757
),
5858
)
59+
5960
SYSTEM_PROMPT: str = dedent("""\
6061
You are a UI interaction analyst specializing in understanding what users
6162
did on web pages from recorded browser interaction events.

bluebox/agents/specialists/network_specialist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class NetworkSpecialist(AbstractAgent):
5353
"inspecting request/response data, and semantic search across captured traffic."
5454
),
5555
)
56+
5657
SYSTEM_PROMPT: str = dedent(f"""
5758
You are a network traffic analyst specializing in captured browser network data.
5859

bluebox/utils/llm_serialization.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""
2+
bluebox/utils/llm_serialization.py
3+
4+
Utilities for controlling what data gets sent to LLMs from tool results.
5+
6+
The LLMExclude marker lets you annotate Pydantic model fields that should be
7+
stripped before a tool result is serialized for the LLM — e.g. large blobs,
8+
internal IDs, or raw data the model doesn't need.
9+
10+
Usage on models::
11+
12+
from typing import Annotated
13+
from pydantic import BaseModel
14+
from bluebox.utils.llm_serialization import LLMExclude
15+
16+
class NetworkTransaction(BaseModel):
17+
url: str
18+
method: str
19+
response_body: Annotated[str, LLMExclude()] # stripped before LLM sees it
20+
21+
Tool handlers can return these models (or dicts containing them) directly —
22+
the agent infrastructure calls strip_llm_excluded() automatically.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import functools
28+
import json
29+
from enum import StrEnum
30+
from typing import Any, NamedTuple
31+
32+
from pydantic import BaseModel
33+
34+
35+
class SerializedContentType(StrEnum):
36+
"""
37+
Content type of a serialized tool result.
38+
39+
Attributes:
40+
JSON: Successfully serialized as JSON.
41+
TEXT: Fell back to ``str()`` representation.
42+
"""
43+
JSON = "json"
44+
TEXT = "text"
45+
46+
47+
class SerializedToolResult(NamedTuple):
48+
"""
49+
Result of serializing a tool return value for the LLM.
50+
51+
Attributes:
52+
serialized: The serialized string (JSON or plain text).
53+
content_type: How the value was serialized.
54+
"""
55+
serialized: str
56+
content_type: SerializedContentType
57+
58+
59+
class LLMExclude:
60+
"""
61+
Marker: exclude this field from LLM tool results.
62+
63+
Attach via ``Annotated``::
64+
65+
name: str # included
66+
raw_blob: Annotated[bytes, LLMExclude()] # excluded
67+
"""
68+
pass
69+
70+
71+
def serialize_tool_result(tool_result: Any) -> SerializedToolResult:
72+
"""
73+
Serialize a tool result to a JSON or plain-text string for the LLM.
74+
75+
Attempts JSON serialization first (using ``default=str`` for non-serializable
76+
types). Falls back to ``str()`` if JSON encoding fails.
77+
78+
Args:
79+
tool_result: The value returned by a tool handler (typically a dict).
80+
81+
Returns:
82+
A :class:`SerializedToolResult` (also unpacks as a two-tuple).
83+
"""
84+
try:
85+
return SerializedToolResult(
86+
serialized=json.dumps(
87+
tool_result,
88+
ensure_ascii=False,
89+
default=str,
90+
indent=2
91+
),
92+
content_type=SerializedContentType.JSON,
93+
)
94+
except (TypeError, ValueError):
95+
return SerializedToolResult(
96+
serialized=str(tool_result),
97+
content_type=SerializedContentType.TEXT
98+
)
99+
100+
101+
@functools.lru_cache(maxsize=256)
102+
def _excluded_fields(model_cls: type[BaseModel]) -> frozenset[str]:
103+
"""
104+
Return the set of field names annotated with LLMExclude for a model class.
105+
106+
Scans ``model_cls.model_fields`` and checks each field's ``metadata`` list
107+
for an ``LLMExclude`` instance (attached via ``Annotated[Type, LLMExclude()]``).
108+
109+
Results are cached per class via ``lru_cache``. Safe because Pydantic field
110+
definitions are fixed at class creation time.
111+
112+
Args:
113+
model_cls: A Pydantic BaseModel subclass to inspect.
114+
115+
Returns:
116+
Frozen set of field names that should be excluded from LLM serialization.
117+
Empty frozenset if the model has no LLMExclude annotations.
118+
"""
119+
return frozenset(
120+
name
121+
for name, info in model_cls.model_fields.items()
122+
if any(isinstance(m, LLMExclude) for m in info.metadata)
123+
)
124+
125+
126+
def strip_llm_excluded(obj: Any) -> Any:
127+
"""
128+
Recursively strip LLMExclude-annotated fields from Pydantic models.
129+
130+
Walks the object tree and converts any ``BaseModel`` instance into a dict
131+
with LLMExclude-annotated fields removed. Non-BaseModel values pass through
132+
unchanged (just an ``isinstance`` check).
133+
134+
Supported containers (recursed into):
135+
- ``BaseModel``: fields filtered, remaining values recursed
136+
- ``dict``: values recursed, keys preserved
137+
- ``list`` / ``tuple``: elements recursed, container type preserved
138+
139+
Args:
140+
obj: Any object — typically a tool handler's return value. Can be a
141+
BaseModel, dict, list, tuple, or primitive.
142+
143+
Returns:
144+
A plain-dict / list / tuple / primitive copy with all LLMExclude fields
145+
removed from any BaseModel instances found at any nesting depth.
146+
"""
147+
if isinstance(obj, BaseModel):
148+
cls = type(obj)
149+
excluded = _excluded_fields(cls)
150+
result = {
151+
name: strip_llm_excluded(value)
152+
for name in cls.model_fields
153+
if name not in excluded
154+
for value in (getattr(obj, name),) # bind to local for clarity
155+
}
156+
# include @computed_field properties (not in model_fields)
157+
for name in cls.model_computed_fields:
158+
if name not in excluded:
159+
result[name] = strip_llm_excluded(getattr(obj, name))
160+
return result
161+
if isinstance(obj, dict):
162+
return {k: strip_llm_excluded(v) for k, v in obj.items()}
163+
if isinstance(obj, list):
164+
return [strip_llm_excluded(item) for item in obj]
165+
if isinstance(obj, tuple):
166+
return tuple(strip_llm_excluded(item) for item in obj)
167+
return obj

0 commit comments

Comments
 (0)