stirrup 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ """Skills loader for agent capabilities.
2
+
3
+ Skills are modular packages that extend agent capabilities with instructions,
4
+ scripts, and resources. Each skill is a directory containing a SKILL.md file
5
+ with YAML frontmatter (name, description) and detailed instructions.
6
+
7
+ Based on: https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class SkillMetadata:
20
+ """Metadata extracted from a skill's SKILL.md frontmatter."""
21
+
22
+ name: str
23
+ description: str
24
+ path: str # Relative path like "skills/data_analysis"
25
+
26
+
27
+ def parse_frontmatter(content: str) -> dict[str, str]:
28
+ """Parse YAML frontmatter from markdown content.
29
+
30
+ Extracts metadata between --- markers at the start of the file.
31
+
32
+ Args:
33
+ content: Markdown content with optional YAML frontmatter
34
+
35
+ Returns:
36
+ Dictionary of frontmatter key-value pairs, empty if no frontmatter found
37
+
38
+ """
39
+ # Match YAML frontmatter between --- markers
40
+ pattern = r"^---\s*\n(.*?)\n---"
41
+ match = re.match(pattern, content, re.DOTALL)
42
+
43
+ if not match:
44
+ return {}
45
+
46
+ frontmatter_text = match.group(1)
47
+ result: dict[str, str] = {}
48
+
49
+ # Simple YAML parsing for key: value pairs
50
+ for line in frontmatter_text.strip().split("\n"):
51
+ line = line.strip()
52
+ if ":" in line:
53
+ key, value = line.split(":", 1)
54
+ result[key.strip()] = value.strip()
55
+
56
+ return result
57
+
58
+
59
+ def load_skills_metadata(skills_dir: Path) -> list[SkillMetadata]:
60
+ """Scan skills directory for SKILL.md files and extract metadata.
61
+
62
+ Args:
63
+ skills_dir: Path to the skills directory
64
+
65
+ Returns:
66
+ List of SkillMetadata for each valid skill found.
67
+ Returns empty list if skills_dir doesn't exist or has no skills.
68
+
69
+ """
70
+ if not skills_dir.exists():
71
+ logger.debug("Skills directory does not exist: %s", skills_dir)
72
+ return []
73
+
74
+ if not skills_dir.is_dir():
75
+ logger.warning("Skills path is not a directory: %s", skills_dir)
76
+ return []
77
+
78
+ skills: list[SkillMetadata] = []
79
+
80
+ for skill_path in skills_dir.iterdir():
81
+ if not skill_path.is_dir():
82
+ continue
83
+
84
+ skill_md = skill_path / "SKILL.md"
85
+ if not skill_md.exists():
86
+ logger.debug("Skill directory missing SKILL.md: %s", skill_path)
87
+ continue
88
+
89
+ try:
90
+ content = skill_md.read_text(encoding="utf-8")
91
+ metadata = parse_frontmatter(content)
92
+
93
+ name = metadata.get("name")
94
+ description = metadata.get("description")
95
+
96
+ if not name or not description:
97
+ logger.warning(
98
+ "Skill %s missing required frontmatter (name, description)",
99
+ skill_path.name,
100
+ )
101
+ continue
102
+
103
+ skills.append(
104
+ SkillMetadata(
105
+ name=name,
106
+ description=description,
107
+ path=f"skills/{skill_path.name}",
108
+ )
109
+ )
110
+ logger.debug("Loaded skill: %s", name)
111
+
112
+ except Exception as e:
113
+ logger.warning("Failed to load skill %s: %s", skill_path.name, e)
114
+
115
+ return skills
116
+
117
+
118
+ def format_skills_section(skills: list[SkillMetadata]) -> str:
119
+ """Format skills metadata as a system prompt section.
120
+
121
+ Args:
122
+ skills: List of skill metadata to include
123
+
124
+ Returns:
125
+ Formatted string for inclusion in system prompt.
126
+ Returns empty string if no skills provided.
127
+
128
+ """
129
+ if not skills:
130
+ return ""
131
+
132
+ lines = [
133
+ "## Available Skills",
134
+ "",
135
+ "You have access to the following skills located in the `skills/` directory. "
136
+ "Each skill contains a SKILL.md file with detailed instructions and potentially bundled scripts.",
137
+ "",
138
+ "To use a skill:",
139
+ "1. Read the full instructions: `cat <skill_path>/SKILL.md`",
140
+ "2. Follow the instructions and use any bundled resources as described",
141
+ "",
142
+ ]
143
+ lines.extend([f"- **{skill.name}**: {skill.description} (`{skill.path}/SKILL.md`)" for skill in skills])
144
+
145
+ return "\n".join(lines)
stirrup/tools/__init__.py CHANGED
@@ -55,6 +55,7 @@ from stirrup.core.models import Tool, ToolProvider
55
55
  from stirrup.tools.calculator import CALCULATOR_TOOL
56
56
  from stirrup.tools.code_backends import CodeExecToolProvider, LocalCodeExecToolProvider
57
57
  from stirrup.tools.finish import SIMPLE_FINISH_TOOL, FinishParams
58
+ from stirrup.tools.user_input import USER_INPUT_TOOL
58
59
  from stirrup.tools.view_image import ViewImageToolProvider
59
60
  from stirrup.tools.web import WebToolProvider
60
61
 
@@ -69,6 +70,7 @@ __all__ = [
69
70
  "CALCULATOR_TOOL",
70
71
  "DEFAULT_TOOLS",
71
72
  "SIMPLE_FINISH_TOOL",
73
+ "USER_INPUT_TOOL",
72
74
  "CodeExecToolProvider",
73
75
  "FinishParams",
74
76
  "LocalCodeExecToolProvider",
@@ -21,7 +21,7 @@ def calculator_executor(params: CalculatorParams) -> ToolResult[ToolUseCountMeta
21
21
  result = eval(params.expression, {"__builtins__": {}}, {})
22
22
  return ToolResult(content=f"Result: {result}", metadata=ToolUseCountMetadata())
23
23
  except Exception as e:
24
- return ToolResult(content=f"Error evaluating expression: {e!s}", metadata=ToolUseCountMetadata())
24
+ return ToolResult(content=f"Error evaluating expression: {e!s}", success=False, metadata=ToolUseCountMetadata())
25
25
 
26
26
 
27
27
  CALCULATOR_TOOL: Tool[CalculatorParams, ToolUseCountMetadata] = Tool[CalculatorParams, ToolUseCountMetadata](
@@ -160,6 +160,11 @@ class CodeExecToolProvider(ToolProvider, ABC):
160
160
  if allowed_commands is not None:
161
161
  self._compiled_allowed = [re.compile(p) for p in allowed_commands]
162
162
 
163
+ @property
164
+ def temp_dir(self) -> Path | None:
165
+ """Return the temporary directory for this execution environment, if any."""
166
+ return None
167
+
163
168
  def _check_allowed(self, cmd: str) -> bool:
164
169
  """Check if command is allowed based on the allowlist.
165
170
 
@@ -419,11 +424,13 @@ class CodeExecToolProvider(ToolProvider, ABC):
419
424
  except FileNotFoundError:
420
425
  return ToolResult(
421
426
  content=f"Image `{params.path}` not found.",
427
+ success=False,
422
428
  metadata=ToolUseCountMetadata(),
423
429
  )
424
430
  except ValueError as e:
425
431
  return ToolResult(
426
432
  content=str(e),
433
+ success=False,
427
434
  metadata=ToolUseCountMetadata(),
428
435
  )
429
436
 
@@ -330,7 +330,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
330
330
  context_path = self._dockerfile_context.resolve() if self._dockerfile_context else dockerfile_path.parent
331
331
 
332
332
  # Generate unique tag based on dockerfile path
333
- tag = f"agent001-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
333
+ tag = f"stirrup-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
334
334
 
335
335
  logger.info("Building image from %s with tag %s", dockerfile_path, tag)
336
336
 
@@ -710,8 +710,20 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
710
710
  logger.debug("Uploaded file: %s -> %s", source, container_path)
711
711
 
712
712
  elif source.is_dir():
713
- dest = dest_base / source.name
714
- shutil.copytree(source, dest, dirs_exist_ok=True)
713
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
714
+ # Otherwise, create a subdirectory with the source's name
715
+ if dest_dir:
716
+ dest = dest_base
717
+ # Copy contents of source directory into dest_base
718
+ for item in source.iterdir():
719
+ item_dest = dest / item.name
720
+ if item.is_file():
721
+ shutil.copy2(item, item_dest)
722
+ else:
723
+ shutil.copytree(item, item_dest, dirs_exist_ok=True)
724
+ else:
725
+ dest = dest_base / source.name
726
+ shutil.copytree(source, dest, dirs_exist_ok=True)
715
727
  # Track all individual files uploaded
716
728
  for file_path in source.rglob("*"):
717
729
  if file_path.is_file():
@@ -725,7 +737,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
725
737
  size=file_path.stat().st_size,
726
738
  ),
727
739
  )
728
- logger.debug("Uploaded directory: %s -> %s/%s", source, self._working_dir, source.name)
740
+ logger.debug("Uploaded directory: %s -> %s", source, dest)
729
741
 
730
742
  except Exception as exc:
731
743
  result.failed[str(source)] = str(exc)
@@ -2,6 +2,8 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ from pydantic import ValidationError
6
+
5
7
  try:
6
8
  from e2b import InvalidArgumentException, TimeoutException
7
9
  from e2b.sandbox.filesystem.filesystem import FileType
@@ -13,7 +15,7 @@ except ImportError as e:
13
15
 
14
16
  import logging
15
17
 
16
- from stirrup.constants import SUBMISSION_SANDBOX_TIMEOUT
18
+ from stirrup.constants import SANDBOX_REQUEST_TIMEOUT, SANDBOX_TIMEOUT
17
19
  from stirrup.core.models import ImageContentBlock, Tool, ToolUseCountMetadata
18
20
 
19
21
  from .base import (
@@ -51,7 +53,8 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
51
53
  def __init__(
52
54
  self,
53
55
  *,
54
- timeout: int = SUBMISSION_SANDBOX_TIMEOUT,
56
+ timeout: int = SANDBOX_TIMEOUT,
57
+ request_timeout: int = SANDBOX_REQUEST_TIMEOUT,
55
58
  template: str | None = None,
56
59
  allowed_commands: list[str] | None = None,
57
60
  ) -> None:
@@ -67,6 +70,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
67
70
  """
68
71
  super().__init__(allowed_commands=allowed_commands)
69
72
  self._timeout = timeout
73
+ self._request_timeout = request_timeout
70
74
  self._template = template
71
75
  self._sbx: AsyncSandbox | None = None
72
76
 
@@ -109,7 +113,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
109
113
  if not await self._sbx.files.exists(path):
110
114
  raise FileNotFoundError(f"File not found: {path}")
111
115
 
112
- file_bytes = await self._sbx.files.read(path, format="bytes")
116
+ file_bytes = await self._sbx.files.read(path, format="bytes", request_timeout=self._request_timeout)
113
117
  return bytes(file_bytes)
114
118
 
115
119
  async def write_file_bytes(self, path: str, content: bytes) -> None:
@@ -126,7 +130,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
126
130
  if self._sbx is None:
127
131
  raise RuntimeError("ExecutionEnvironment not started.")
128
132
 
129
- await self._sbx.files.write(path, content)
133
+ await self._sbx.files.write(path, content, request_timeout=self._request_timeout)
130
134
 
131
135
  async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
132
136
  """Execute command in E2B execution environment, returning raw CommandResult."""
@@ -146,7 +150,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
146
150
  )
147
151
 
148
152
  try:
149
- r = await self._sbx.commands.run(cmd, timeout=timeout)
153
+ r = await self._sbx.commands.run(cmd, timeout=timeout, request_timeout=self._request_timeout)
150
154
 
151
155
  return CommandResult(
152
156
  exit_code=getattr(r, "exit_code", 0),
@@ -231,7 +235,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
231
235
  continue
232
236
 
233
237
  # Read file content from execution environment
234
- file_bytes = await self._sbx.files.read(env_path, format="bytes")
238
+ file_bytes = await self._sbx.files.read(env_path, format="bytes", request_timeout=self._request_timeout)
235
239
  content = bytes(file_bytes)
236
240
 
237
241
  # Save with original filename directly in output_dir
@@ -297,6 +301,7 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
297
301
  result = UploadFilesResult()
298
302
 
299
303
  for source in paths:
304
+ original_name = Path(source).name # Get name BEFORE resolve
300
305
  source = Path(source).resolve()
301
306
 
302
307
  if not source.exists():
@@ -306,9 +311,10 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
306
311
 
307
312
  try:
308
313
  if source.is_file():
309
- dest = f"{dest_base}/{source.name}"
314
+ source = Path(source).resolve()
315
+ dest = f"{dest_base}/{original_name}"
310
316
  content = source.read_bytes()
311
- await self._sbx.files.write(dest, content)
317
+ await self._sbx.files.write(dest, content, request_timeout=self._request_timeout)
312
318
  result.uploaded.append(
313
319
  UploadedFile(
314
320
  source_path=source,
@@ -320,12 +326,14 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
320
326
 
321
327
  elif source.is_dir():
322
328
  # Upload all files in directory recursively
329
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
330
+ # Otherwise, create a subdirectory with the source's name
323
331
  for file_path in source.rglob("*"):
324
332
  if file_path.is_file():
325
333
  relative = file_path.relative_to(source)
326
- dest = f"{dest_base}/{source.name}/{relative}"
334
+ dest = f"{dest_base}/{relative}" if dest_dir else f"{dest_base}/{source.name}/{relative}"
327
335
  content = file_path.read_bytes()
328
- await self._sbx.files.write(dest, content)
336
+ await self._sbx.files.write(dest, content, request_timeout=self._request_timeout)
329
337
  result.uploaded.append(
330
338
  UploadedFile(
331
339
  source_path=file_path,
@@ -333,7 +341,10 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
333
341
  size=len(content),
334
342
  ),
335
343
  )
336
- logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
344
+ if dest_dir:
345
+ logger.debug("Uploaded directory contents: %s -> %s", source, dest_base)
346
+ else:
347
+ logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
337
348
 
338
349
  except Exception as exc:
339
350
  result.failed[str(source)] = str(exc)
@@ -355,5 +366,13 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
355
366
  FileNotFoundError: If file does not exist.
356
367
 
357
368
  """
358
- file_bytes = await self.read_file_bytes(path)
359
- return ImageContentBlock(data=file_bytes)
369
+ if not path.lower().endswith((".png", ".jpg", ".jpeg")):
370
+ raise ValueError(f"Unsupported image type for `{path}`. Only .png, .jpg, or .jpeg are allowed.")
371
+
372
+ try:
373
+ file_bytes = await self.read_file_bytes(path)
374
+ image = ImageContentBlock(data=file_bytes)
375
+ except ValidationError as e:
376
+ raise ValueError("You submitted a corrupt/unsupported image file.") from e
377
+
378
+ return image
@@ -122,7 +122,7 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
122
122
  if self._temp_base_dir:
123
123
  self._temp_base_dir.mkdir(parents=True, exist_ok=True)
124
124
  self._temp_dir = Path(tempfile.mkdtemp(prefix="local_exec_env_", dir=self._temp_base_dir))
125
- logger.info("Created local execution environment temp directory: %s", self._temp_dir)
125
+ logger.debug("Created local execution environment temp directory: %s", self._temp_dir)
126
126
  return self.get_code_exec_tool(description=self._description)
127
127
 
128
128
  async def __aexit__(
@@ -359,7 +359,7 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
359
359
 
360
360
  # Move file (overwrites if exists)
361
361
  shutil.move(str(source_path), str(dest_path))
362
- logger.info("Moved file: %s -> %s", source_path, dest_path)
362
+ logger.debug("Moved file: %s -> %s", source_path, dest_path)
363
363
 
364
364
  result.saved.append(
365
365
  SavedFile(
@@ -440,8 +440,20 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
440
440
  logger.debug("Uploaded file: %s -> %s", source, dest)
441
441
 
442
442
  elif source.is_dir():
443
- dest = dest_base / source.name
444
- shutil.copytree(source, dest, dirs_exist_ok=True)
443
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
444
+ # Otherwise, create a subdirectory with the source's name
445
+ if dest_dir:
446
+ dest = dest_base
447
+ # Copy contents of source directory into dest_base
448
+ for item in source.iterdir():
449
+ item_dest = dest / item.name
450
+ if item.is_file():
451
+ shutil.copy2(item, item_dest)
452
+ else:
453
+ shutil.copytree(item, item_dest, dirs_exist_ok=True)
454
+ else:
455
+ dest = dest_base / source.name
456
+ shutil.copytree(source, dest, dirs_exist_ok=True)
445
457
  # Track all individual files uploaded
446
458
  for file_path in source.rglob("*"):
447
459
  if file_path.is_file():
stirrup/tools/finish.py CHANGED
@@ -19,5 +19,5 @@ SIMPLE_FINISH_TOOL: Tool[FinishParams, ToolUseCountMetadata] = Tool[FinishParams
19
19
  name=FINISH_TOOL_NAME,
20
20
  description="Signal task completion with a reason. Use when the task is finished or cannot proceed further. Note that you will need a separate turn to finish.",
21
21
  parameters=FinishParams,
22
- executor=lambda params: ToolResult(content=params.reason, metadata=ToolUseCountMetadata()),
22
+ executor=lambda params: ToolResult(content=params.reason, metadata=ToolUseCountMetadata(), success=True),
23
23
  )
@@ -0,0 +1,130 @@
1
+ """User input tool for interactive clarification during agent execution.
2
+
3
+ This module provides the user_input tool that allows agents to ask questions
4
+ and receive text responses from users during task execution.
5
+
6
+ Example usage:
7
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
8
+ from stirrup.tools import DEFAULT_TOOLS, USER_INPUT_TOOL
9
+
10
+ client = ChatCompletionsClient(model="gpt-5")
11
+ agent = Agent(
12
+ client=client,
13
+ name="assistant",
14
+ tools=[*DEFAULT_TOOLS, USER_INPUT_TOOL],
15
+ )
16
+
17
+ async with agent.session() as session:
18
+ await session.run("Help me configure my project")
19
+ """
20
+
21
+ from typing import Annotated, Literal
22
+
23
+ from pydantic import BaseModel, Field
24
+ from rich.panel import Panel
25
+ from rich.prompt import Confirm, Prompt
26
+
27
+ from stirrup.core.models import Tool, ToolResult, ToolUseCountMetadata
28
+ from stirrup.utils.logging import AgentLoggerBase, console
29
+
30
+ __all__ = ["USER_INPUT_TOOL", "UserInputParams"]
31
+
32
+
33
+ class UserInputParams(BaseModel):
34
+ """Parameters for asking the user a single question.
35
+
36
+ Supports three question types:
37
+ - "text": Free-form text input (default)
38
+ - "choice": Multiple choice from a list of options
39
+ - "confirm": Yes/no confirmation
40
+ """
41
+
42
+ question: Annotated[str, Field(description="A single question to ask the user (*not* multiple questions)")]
43
+ question_type: Annotated[
44
+ Literal["text", "choice", "confirm"],
45
+ Field(
46
+ default="text",
47
+ description="Type of question: 'text' for free-form, 'choice' for multiple choice, 'confirm' for yes/no",
48
+ ),
49
+ ]
50
+ choices: Annotated[
51
+ list[str] | None,
52
+ Field(default=None, description="List of valid choices (required when question_type is 'choice')"),
53
+ ]
54
+ default: Annotated[
55
+ str,
56
+ Field(default="", description="Default value if user presses Enter without input"),
57
+ ]
58
+
59
+
60
+ def _get_logger() -> "AgentLoggerBase | None":
61
+ """Get the current session's logger for pause/resume.
62
+
63
+ Returns the logger from SessionState if available, None otherwise.
64
+ """
65
+ from stirrup.core.agent import _SESSION_STATE
66
+
67
+ state = _SESSION_STATE.get(None)
68
+ return state.logger if state else None
69
+
70
+
71
+ def user_input_executor(params: UserInputParams) -> ToolResult[ToolUseCountMetadata]:
72
+ """Prompt the user for input and return their response."""
73
+ logger = _get_logger()
74
+
75
+ # Pause spinner before prompting
76
+ if logger:
77
+ logger.pause_live()
78
+
79
+ try:
80
+ # Print newline to separate from spinner, then display question in a styled panel
81
+ console.print()
82
+ panel = Panel(
83
+ params.question,
84
+ title="[bold cyan]🤔 Agent Question[/]",
85
+ title_align="left",
86
+ border_style="cyan",
87
+ padding=(0, 1),
88
+ )
89
+ console.print(panel)
90
+
91
+ # Get user input based on question type
92
+ if params.question_type == "confirm":
93
+ # Yes/no confirmation
94
+ default_bool = params.default.lower() in ("yes", "y", "true", "1") if params.default else False
95
+ result = Confirm.ask("[bold]Your answer[/]", default=default_bool, console=console)
96
+ answer = "yes" if result else "no"
97
+
98
+ elif params.question_type == "choice" and params.choices:
99
+ # Multiple choice
100
+ answer = Prompt.ask(
101
+ "[bold]Your answer[/]",
102
+ choices=params.choices,
103
+ default=params.default,
104
+ console=console,
105
+ )
106
+
107
+ else:
108
+ # Free-form text (default)
109
+ answer = Prompt.ask("[bold]Your answer[/]", default=params.default or "", console=console)
110
+
111
+ return ToolResult(content=answer, metadata=ToolUseCountMetadata())
112
+
113
+ finally:
114
+ # Always resume spinner, even if an exception occurs
115
+ if logger:
116
+ logger.resume_live()
117
+
118
+
119
+ USER_INPUT_TOOL: Tool[UserInputParams, ToolUseCountMetadata] = Tool(
120
+ name="user_input",
121
+ description=(
122
+ "Ask the user a question when you need clarification or are uncertain. "
123
+ "Supports three types: 'text' for free-form input, 'choice' for multiple choice "
124
+ "(provide choices list), 'confirm' for yes/no questions. Returns the user's response."
125
+ "There should only EVER be one question per call to this tool."
126
+ "If you need to ask multiple questions, you should call this tool multiple times."
127
+ ),
128
+ parameters=UserInputParams,
129
+ executor=user_input_executor,
130
+ )
stirrup/tools/web.py CHANGED
@@ -125,6 +125,7 @@ def _get_fetch_web_page_tool(client: httpx.AsyncClient | None = None) -> Tool[Fe
125
125
  return ToolResult(
126
126
  content=f"<web_fetch><url>{params.url}</url><error>"
127
127
  f"{truncate_msg(str(exc), MAX_LENGTH_WEB_FETCH_HTML)}</error></web_fetch>",
128
+ success=False,
128
129
  metadata=WebFetchMetadata(pages_fetched=[params.url]),
129
130
  )
130
131
 
stirrup/utils/logging.py CHANGED
@@ -27,6 +27,7 @@ from stirrup.core.models import AssistantMessage, ToolMessage, UserMessage, _agg
27
27
  __all__ = [
28
28
  "AgentLogger",
29
29
  "AgentLoggerBase",
30
+ "console",
30
31
  ]
31
32
 
32
33
  # Shared console instance
@@ -247,6 +248,12 @@ class AgentLoggerBase(ABC):
247
248
  """Log an error message."""
248
249
  ...
249
250
 
251
+ def pause_live(self) -> None: # noqa: B027
252
+ """Pause live display (e.g., spinner) before user interaction."""
253
+
254
+ def resume_live(self) -> None: # noqa: B027
255
+ """Resume live display after user interaction."""
256
+
250
257
 
251
258
  class AgentLogger(AgentLoggerBase):
252
259
  """Rich console logger for agent workflows.
@@ -655,6 +662,23 @@ class AgentLogger(AgentLoggerBase):
655
662
  if self._live:
656
663
  self._live.update(self._make_spinner())
657
664
 
665
+ def pause_live(self) -> None:
666
+ """Pause the live spinner display.
667
+
668
+ Call this before prompting for user input to prevent the spinner
669
+ from interfering with the input prompt.
670
+ """
671
+ if self._live is not None:
672
+ self._live.stop()
673
+
674
+ def resume_live(self) -> None:
675
+ """Resume the live spinner display.
676
+
677
+ Call this after user input is complete to restart the spinner.
678
+ """
679
+ if self._live is not None:
680
+ self._live.start()
681
+
658
682
  def set_level(self, level: int) -> None:
659
683
  """Set the logging level."""
660
684
  self._level = level