Workspace and memory¶
A workspace is a set of files under one root that tools can read and change.
from nodestep.workspace import InMemoryWorkspace
workspace = InMemoryWorkspace()
workspace.write_file("notes/todo.md", "- ship docs\n- fix bug\n")
print(workspace.list_dir("notes"))
print(workspace.edit("notes/todo.md", "fix bug", "fix the login bug"))
print(workspace.grep("login").matches)
['todo.md']
1
{'notes/todo.md': ['- fix the login bug']}
edit returns how many replacements it made, and grep returns the matching lines by file. For long-term memory, FilesystemMemory gives a model four tools; see Memory.
Workspaces¶
Pass a workspace to Graph(workspace=...) or build_react_agent(workspace=...). Tools and nodes read it as ctx.workspace.
| Workspace | Files |
|---|---|
LocalWorkspace |
A directory on disk |
InMemoryWorkspace |
Kept in memory, with the same path rules, for tests |
Both have read_file, write_file, list_dir, exists, delete, edit, glob and grep. Paths are relative to the root, and a path that could leave it raises PathAccessError. The rules are the same on every operating system.
from nodestep import PathAccessError
from nodestep.workspace import InMemoryWorkspace
workspace = InMemoryWorkspace()
for path in ["../secrets.txt", "/etc/passwd", "notes/CON"]:
try:
workspace.read_file(path)
except PathAccessError as error:
print(error)
Path '../secrets.txt' is outside the workspace root
Path '/etc/passwd' is absolute; use a relative path
Path 'notes/CON' uses the reserved device name 'CON'
The first path climbs above the root, the second is absolute, and the third is a Windows device name.
Tools that use a workspace¶
A workspace has no tools of its own. Write the tools you want, and read the workspace from the ToolContext.
import asyncio
from nodestep import ScriptedChat, ToolContext, build_react_agent, run_agent, tool
from nodestep.chat import ChatResponse, ToolCall
from nodestep.workspace import InMemoryWorkspace
@tool
def read_note(path: str, ctx: ToolContext) -> str:
"""Read a note from the workspace."""
return ctx.workspace.read_file(path)
workspace = InMemoryWorkspace()
workspace.write_file("release.md", "Release on Friday.")
chat = ScriptedChat(
[
ChatResponse(
tool_calls=[
ToolCall(
id="call_1", name="read_note", arguments={"path": "release.md"}
)
]
),
"The release is on Friday.",
]
)
agent = build_react_agent(chat, tools=[read_note], workspace=workspace)
print(asyncio.run(run_agent(agent, "When is the release?")))
print(chat.requests[1].messages[-1].content)
The release is on Friday.
{"result":"Release on Friday."}
The second line is the tool message the model received with its second request.
Memory¶
FilesystemMemory(root_dir, scope=...) gives a model long-term memory as four tools:
save_memorysaves an entry under a key.search_memoryfinds entries, ranked with BM25.list_memorieslists the entries.delete_memorydeletes an entry.
The scope is set in code, never by the model: the tools have no scope argument. scope= is a string such as "user:42", or a function of the call's ToolContext, such as lambda ctx: f"user:{ctx.context.user_id}".
import asyncio
import tempfile
from pathlib import Path
from nodestep import ToolContext, call_tool
from nodestep.middleware import FilesystemMemory
async def main(directory: Path) -> None:
memory = FilesystemMemory(directory, scope="user:42")
tools = {memory_tool.name: memory_tool for memory_tool in memory.tools()}
print(sorted(tools))
await call_tool(
tools["save_memory"],
{"key": "language", "title": "Language", "content": "Prefers Spanish"},
ToolContext(),
)
found = await call_tool(tools["search_memory"], {"query": "spanish"}, ToolContext())
print([entry.key for entry in found.value.results])
print(
sorted(
path.relative_to(directory).as_posix() for path in directory.rglob("*.json")
)
)
with tempfile.TemporaryDirectory() as directory:
asyncio.run(main(Path(directory)))
['delete_memory', 'list_memories', 'save_memory', 'search_memory']
['language']
['memories/user/42/language.json']
A node gets the memory tools only when you list them, as in tools=[*memory.tools(), search], or pass the memory to build_react_agent(middleware=[memory]). The memory_agent.py example uses these tools in a graph.
Rules and defaults¶
| Rule or setting | What happens |
|---|---|
LocalWorkspace(root_dir, create=False) |
A missing root raises WorkspaceError unless create=True |
| Paths | Relative to the workspace root |
| A path that could leave the root | PathAccessError: .. above the root, absolute paths, drive and UNC paths, NTFS alternate data streams, Windows device names such as CON and NUL, and segments that end in a dot or a space |
| Path rules on each operating system | The same everywhere, for both workspaces |
Symlinks in a LocalWorkspace |
Refused when they point outside the root |
list_dir, glob and grep on a missing path |
FileNotFoundError |
grep result |
The matching lines by file, plus the files it could not read in skipped |
edit result |
How many replacements it made; an empty old text is refused |
| Tools of a workspace | None; tools read the workspace as ctx.workspace |
| Memory entries | UTF-8 JSON files under root_dir |
search_memory ranking |
BM25 |
| Memory scope | Set in code, never by the model: the tools have no scope argument |
scope= |
A string such as "user:42", or a function of the call's ToolContext |
| Where entries live | Entries of scope "user:42" live in root_dir/memories/user/42/ |
| Memory tools on a node | Only when you list them, as in tools=[*memory.tools(), search], or pass the memory to build_react_agent(middleware=[memory]) |
| Memory files that cannot be read | Listed in the skipped field of search and list results |