simbiot-harness/venv/lib/python3.12/site-packages/strands/vended_tools/notebook/notebook.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

284 lines
11 KiB
Python

"""Notebook tool for managing persistent text notebooks in agent state.
Notebooks are stored on the agent's :attr:`~strands.Agent.state` under the
``notebooks`` key and persist within the agent session.
The tool is a thin wrapper over ``agent.state`` — persistence, isolation, and
serialization all follow whatever the caller configured for agent state.
Supported operations: ``create``, ``list``, ``read``, ``write`` (append,
string replacement, or line insertion), and ``clear``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from ...tools.decorator import tool
from ...types.tools import ToolContext
from .types import DEFAULT_NOTEBOOK_DESCRIPTION, _NotebookState
if TYPE_CHECKING:
from ...tools.decorator import DecoratedFunctionTool
_DEFAULT_MAX_NOTEBOOK_SIZE_BYTES = 1_048_576 # 1 MiB
_DEFAULT_NOTEBOOK_NAME = "default"
_STATE_KEY = "notebooks"
# Modes that alter ``notebooks`` state. Only these persist a write.
_MUTATING_MODES = frozenset({"create", "write", "clear"})
# Modes that can grow state. Only these need size-cap enforcement.
_GROWING_MODES = frozenset({"create", "write"})
def make_notebook(
*,
name: str = "notebook",
description: str = DEFAULT_NOTEBOOK_DESCRIPTION,
max_notebook_size_bytes: int = _DEFAULT_MAX_NOTEBOOK_SIZE_BYTES,
) -> DecoratedFunctionTool:
"""Create a notebook tool.
Args:
name: Tool name exposed to the model. Defaults to ``"notebook"``.
description: Tool description shown to the model.
max_notebook_size_bytes: Maximum size of a single notebook's content
in bytes (UTF-8 encoded). Defaults to 1 MiB.
Returns:
A decorated tool that manages text notebooks in agent state.
Raises:
ValueError: If ``name`` is empty, or ``max_notebook_size_bytes`` is not
a positive integer.
"""
if not name:
raise ValueError("name must be a non-empty string")
if (
not isinstance(max_notebook_size_bytes, int)
or isinstance(max_notebook_size_bytes, bool)
or max_notebook_size_bytes < 1
):
raise ValueError("max_notebook_size_bytes must be a positive integer")
@tool(name=name, description=description, context="tool_context")
async def notebook_tool(
mode: Literal["create", "list", "read", "write", "clear"],
tool_context: ToolContext,
name: str | None = None,
new_str: str | None = None,
old_str: str | None = None,
insert_line: int | str | None = None,
read_range: list[int] | None = None,
) -> str:
"""Manages text notebooks for note-taking and documentation.
Supports `create`, `list`, `read`, `write` (append, replace, or insert), and `clear`
operations. Notebooks persist across invocations within a session.
Args:
mode: The operation to perform: `create`, `list`, `read`, `write`, `clear`.
tool_context: Injected by the framework. Not user-facing.
name: Name of the notebook to operate on. Defaults to "default".
new_str: Content for create (initial text), write/append (text to append),
or write/replace and write/insert.
old_str: String to replace in write mode when doing text replacement.
insert_line: Line number (int) or search text (str) for insertion point in write mode.
Supports negative indices.
read_range: Optional parameter of `read` command. Line range to show [start, end].
Supports negative indices.
"""
state = tool_context.agent.state
notebooks_obj: _NotebookState | None = state.get(_STATE_KEY)
# AgentState.get deep-copies; guard shape strictly to catch corruption from sibling tools.
if notebooks_obj is None:
notebooks: _NotebookState = {}
elif isinstance(notebooks_obj, dict):
for k, v in notebooks_obj.items():
if not isinstance(k, str) or not isinstance(v, str):
raise ValueError("Malformed notebooks state: keys and values must be strings")
notebooks = notebooks_obj
else:
raise ValueError("Malformed notebooks state: expected a dict")
# Ensure default notebook exists in-memory; only persisted if this mode mutates state.
if not notebooks:
notebooks[_DEFAULT_NOTEBOOK_NAME] = ""
target = name if name is not None else _DEFAULT_NOTEBOOK_NAME
if not isinstance(target, str):
raise ValueError("Notebook name must be a string")
if mode == "create":
result = _handle_create(notebooks, target, new_str)
elif mode == "list":
result = _handle_list(notebooks)
elif mode == "read":
result = _handle_read(notebooks, target, read_range)
elif mode == "write":
_validate_write_params(old_str, new_str, insert_line)
result = _handle_write(notebooks, target, old_str, new_str, insert_line)
elif mode == "clear":
result = _handle_clear(notebooks, target)
else: # pragma: no cover - Literal narrows this at type-check time.
raise ValueError(f"Unknown mode: {mode}")
if mode in _GROWING_MODES:
size = len(notebooks[target].encode("utf-8"))
if size > max_notebook_size_bytes:
raise ValueError(
f"Notebook '{target}' content ({size} bytes) would exceed"
f" maximum of {max_notebook_size_bytes} bytes"
)
if mode in _MUTATING_MODES:
state.set(_STATE_KEY, notebooks)
return result
return notebook_tool
notebook = make_notebook()
"""Default notebook tool."""
# ---- Internals ----
def _validate_write_params(old_str: str | None, new_str: str | None, insert_line: int | str | None) -> None:
"""Validate the parameter combination for a write operation.
Args:
old_str: The string to replace, if this is a replacement.
new_str: The replacement or inserted text.
insert_line: The insertion anchor (line number or search text), if this is an insertion.
Raises:
ValueError: If neither valid combination is present.
"""
has_replacement = old_str is not None and new_str is not None
has_insertion = insert_line is not None and new_str is not None
has_append = old_str is None and insert_line is None and new_str is not None
if not (has_replacement or has_insertion or has_append):
raise ValueError(
"Write operation requires either new_str alone (append), "
"(old_str + new_str) for replacement, "
"or (insert_line + new_str) for insertion"
)
if old_str is not None and insert_line is not None:
raise ValueError(
"Write operation is ambiguous: pass either `old_str` (replace) or `insert_line` (insert), not both"
)
def _handle_create(notebooks: _NotebookState, name: str, new_str: str | None) -> str:
notebooks[name] = new_str if new_str is not None else ""
suffix = " with specified content" if new_str else " (empty)"
return f"Created notebook '{name}'{suffix}"
def _handle_list(notebooks: _NotebookState) -> str:
lines = []
for nb_name, content in notebooks.items():
line_count = len(content.split("\n")) if content else 0
status = "Empty" if line_count == 0 else f"{line_count} lines"
lines.append(f"- {nb_name}: {status}")
return "Available notebooks:\n" + "\n".join(lines)
def _handle_read(notebooks: _NotebookState, name: str, read_range: list[int] | None) -> str:
if name not in notebooks:
raise ValueError(f"Notebook '{name}' not found")
content = notebooks[name]
if read_range is None:
return content if content else f"Notebook '{name}' is empty"
if len(read_range) != 2:
raise ValueError("`read_range` must be a list of two integers: `[start, end]`")
lines = content.split("\n")
start, end = read_range[0], read_range[1]
if start < 0:
start = len(lines) + start + 1
if end < 0:
end = len(lines) + end + 1
selected: list[str] = []
for line_num in range(max(start, 1), min(end, len(lines)) + 1):
selected.append(f"{line_num}: {lines[line_num - 1]}")
if not selected:
return (
f"No lines found in range [{read_range[0]}, {read_range[1]}]. Notebook '{name}' has {len(lines)} line(s)."
)
return "\n".join(selected)
def _handle_write(
notebooks: _NotebookState,
name: str,
old_str: str | None,
new_str: str | None,
insert_line: int | str | None,
) -> str:
if name not in notebooks:
raise ValueError(f"Notebook '{name}' not found")
# Append mode: new_str alone, no replacement or insertion anchor.
if old_str is None and insert_line is None and new_str is not None:
if len(new_str) == 0:
return f"No changes made to notebook '{name}'"
content = notebooks[name]
separator = "\n" if content and not content.endswith("\n") else ""
notebooks[name] = f"{content}{separator}{new_str}"
return f"Appended text to notebook '{name}'"
# String replacement mode.
if old_str is not None and new_str is not None:
if old_str not in notebooks[name]:
raise ValueError(f"String '{old_str}' not found in notebook '{name}'")
notebooks[name] = notebooks[name].replace(old_str, new_str, 1)
return f"Replaced text in notebook '{name}'"
# Line insertion mode.
if insert_line is not None and new_str is not None:
lines = notebooks[name].split("\n")
if isinstance(insert_line, str):
line_num = -1
for i, line in enumerate(lines):
if insert_line in line:
line_num = i
break
if line_num == -1:
raise ValueError(f"Text '{insert_line}' not found in notebook '{name}'")
elif isinstance(insert_line, int):
if insert_line < 0:
line_num = len(lines) + insert_line
else:
line_num = insert_line - 1
else:
raise ValueError("`insert_line` must be an integer or string")
if line_num < -1 or line_num > len(lines):
raise ValueError("Line number out of range")
lines.insert(line_num + 1, new_str)
notebooks[name] = "\n".join(lines)
return f"Inserted text at line {line_num + 2} in notebook '{name}'"
raise ValueError("Invalid write operation")
def _handle_clear(notebooks: _NotebookState, name: str) -> str:
if name not in notebooks:
raise ValueError(f"Notebook '{name}' not found")
notebooks[name] = ""
return f"Cleared notebook '{name}'"