|
61 | 61 | get_workaround_for_error, |
62 | 62 | ) |
63 | 63 | from bluebox.utils.data_utils import format_bytes |
| 64 | +from bluebox.utils.llm_serialization import serialize_tool_result, strip_llm_excluded |
64 | 65 | from bluebox.utils.llm_utils import token_optimized as token_optimized_decorator |
65 | 66 | from bluebox.utils.logger import get_logger |
66 | 67 |
|
67 | 68 | logger = get_logger(name=__name__) |
68 | 69 |
|
69 | 70 |
|
| 71 | +# Keep persisted tool previews small so iterative runs don't bloat context |
| 72 | +PERSISTED_TOOL_PREVIEW_MAX_CHARS = 800 |
| 73 | + |
| 74 | + |
70 | 75 | 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 | + """ |
71 | 89 | NEVER = "never" |
72 | 90 | ALWAYS = "always" |
73 | 91 | OVERFLOW = "overflow" |
74 | 92 |
|
75 | 93 |
|
76 | | -# Keep persisted tool previews small so iterative runs don't blow context. |
77 | | -PERSISTED_TOOL_PREVIEW_MAX_CHARS = 800 |
78 | | - |
79 | | - |
80 | 94 | 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 | + """ |
82 | 105 | CONVERSATIONAL = "conversational" |
83 | 106 | AUTONOMOUS = "autonomous" |
84 | 107 |
|
@@ -108,36 +131,75 @@ class variable. Orchestrator agents use these cards to discover subagent |
108 | 131 |
|
109 | 132 | @dataclass(frozen=True) |
110 | 133 | 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] |
116 | 159 | persist: ToolResultPersistMode = ToolResultPersistMode.NEVER |
117 | 160 | max_characters: int = 10_000 |
118 | 161 | token_optimized: bool = False |
119 | 162 |
|
120 | 163 |
|
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. |
126 | 168 |
|
| 169 | + Args: |
| 170 | + scope: Raw scope value from a tool call (e.g. ``"Workspace"``). |
127 | 171 |
|
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 | + """ |
130 | 178 | normalized_scope = scope.strip().lower() |
131 | 179 | if normalized_scope not in {"workspace", "docs"}: |
132 | 180 | raise ValueError("scope must be 'workspace' or 'docs'") |
133 | 181 | return normalized_scope |
134 | 182 |
|
135 | 183 |
|
136 | 184 | 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 | + """ |
138 | 197 | seen: set[str] = set() |
139 | 198 | 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 | + ): |
141 | 203 | term = token.strip() |
142 | 204 | if term and term not in seen: |
143 | 205 | seen.add(term) |
@@ -551,7 +613,7 @@ def _maybe_persist_tool_result( |
551 | 613 | if persist_mode == ToolResultPersistMode.NEVER: |
552 | 614 | return tool_result |
553 | 615 |
|
554 | | - serialized, content_type = _serialize_tool_result(tool_result) |
| 616 | + serialized, content_type = serialize_tool_result(tool_result) |
555 | 617 | char_count = len(serialized) |
556 | 618 |
|
557 | 619 | 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[ |
1080 | 1142 | logger.debug("Executing tool %s with arguments: %s", tool_name, tool_arguments) |
1081 | 1143 | # handler is unbound (from cls, not self) so pass self explicitly |
1082 | 1144 | raw_result = handler(self, **validated_arguments) |
| 1145 | + raw_result = strip_llm_excluded(raw_result) # strip LLMExclude-annotated fields from any Pydantic models |
1083 | 1146 | result_for_llm = self._maybe_persist_tool_result( |
1084 | 1147 | tool_name=tool_name, |
1085 | 1148 | tool_meta=tool_meta, |
|
0 commit comments