diff --git a/docs/COGITATE.md b/docs/COGITATE.md index d1d10a1d8..62b902edb 100644 --- a/docs/COGITATE.md +++ b/docs/COGITATE.md @@ -67,7 +67,7 @@ syntax into convey API calls. A talent therefore: - **never** talks to the convey HTTP API directly, and - **never** assumes a database, a socket, or any journal access other than the - `sol` CLI and (when provided) the bounded raw-read tools below. + `sol` CLI and the bounded raw-read tools below. The shell tool is invoked like `sol(command="sol call activities list")`. It is a **shell-based** surface (the talent emits a `sol ...` command line); it is not a @@ -98,9 +98,10 @@ cost — the denylist and caps are the safety bound, not a permission gate. An optional per-talent `read_scope` is a **narrowing hint** only; it is not the default gate, and a talent never fails for reading an undeclared journal file. -> The raw-read **tools** are built to this contract; a given run exposes them only -> if its tier includes them. Check the run's actual tool schema -> (`journal talent show --prompt`) for what a specific talent receives. +> The raw-read **tools** are built to this contract and are currently registered +> on every cogitate run. Per-tier gating is a later milestone; check the run's +> actual tool schema (`journal talent show --prompt`) for what a specific +> talent receives. ## Writes: only through `sol` domain commands @@ -181,7 +182,7 @@ You are a solstone cogitate talent running inside the live system. This runtime - Reach the journal through the `sol` command line: run `sol` / `sol call ...` commands via the provided shell tool, e.g. sol(command="sol call activities list"). The `sol` CLI is the one authoritative path between you and the journal; never assume direct database, socket, or HTTP access. - Write journal state only through `sol` domain commands (the `sol call ...` verbs for the data you own). There is no general-purpose write tool; persistence that does not go through a `sol` domain command will not happen. -- Raw evidence reads, when this run provides a read tool, are bounded to the journal root: a denylist (`.git`, caches, credentials, virtualenvs, `node_modules`) and per-call / per-run caps apply. Prefer `sol call` reads; use raw reads only for evidence that has no `sol` command. +- Raw evidence reads use the provided read tools (`read_file`, `list_directory`, `glob`, `grep_search`), bounded to the journal root: a denylist (`.git`, caches, credentials, virtualenvs, `node_modules`) and per-call / per-run caps apply. Prefer `sol call` reads; use raw reads only for evidence that has no `sol` command. - Finalize as your run is configured: call `emit_final` when an `emit_final` tool is present; otherwise finish through the built-in finish tool; a side-effect-only talent that has already persisted its work finishes quietly with no output. - Do not assume tools or context you were not given: no bare `journal ...` commands, no raw `cat` / `ls` / shell file reads, no auto-loaded skills or AGENTS.md / CLAUDE.md, no browser or web access, no MCP tools, and no delegating to sub-agents. Any guidance file is a normal journal file with no special status; this contract is your source of truth. ``` diff --git a/solstone/think/cogitate_contract.py b/solstone/think/cogitate_contract.py index 20cce4f0e..9a557ab36 100644 --- a/solstone/think/cogitate_contract.py +++ b/solstone/think/cogitate_contract.py @@ -17,7 +17,7 @@ You are a solstone cogitate talent running inside the live system. This runtime - Reach the journal through the `sol` command line: run `sol` / `sol call ...` commands via the provided shell tool, e.g. sol(command="sol call activities list"). The `sol` CLI is the one authoritative path between you and the journal; never assume direct database, socket, or HTTP access. - Write journal state only through `sol` domain commands (the `sol call ...` verbs for the data you own). There is no general-purpose write tool; persistence that does not go through a `sol` domain command will not happen. -- Raw evidence reads, when this run provides a read tool, are bounded to the journal root: a denylist (`.git`, caches, credentials, virtualenvs, `node_modules`) and per-call / per-run caps apply. Prefer `sol call` reads; use raw reads only for evidence that has no `sol` command. +- Raw evidence reads use the provided read tools (`read_file`, `list_directory`, `glob`, `grep_search`), bounded to the journal root: a denylist (`.git`, caches, credentials, virtualenvs, `node_modules`) and per-call / per-run caps apply. Prefer `sol call` reads; use raw reads only for evidence that has no `sol` command. - Finalize as your run is configured: call `emit_final` when an `emit_final` tool is present; otherwise finish through the built-in finish tool; a side-effect-only talent that has already persisted its work finishes quietly with no output. - Do not assume tools or context you were not given: no bare `journal ...` commands, no raw `cat` / `ls` / shell file reads, no auto-loaded skills or AGENTS.md / CLAUDE.md, no browser or web access, no MCP tools, and no delegating to sub-agents. Any guidance file is a normal journal file with no special status; this contract is your source of truth. """ diff --git a/solstone/think/providers/openhands.py b/solstone/think/providers/openhands.py index 9d4c77583..126e7953e 100644 --- a/solstone/think/providers/openhands.py +++ b/solstone/think/providers/openhands.py @@ -791,6 +791,7 @@ async def run_cogitate( read_call_budget = int( config.get("read_call_budget", DEFAULT_READ_CALL_BUDGET) or 0 ) + journal = Path(get_journal()) llm = _build_llm(provider, model) usage_start = _usage_snapshot(llm) sol_tools, _executor = _build_sol_tools( @@ -805,6 +806,16 @@ async def run_cogitate( # reference it by name. register_tool("sol", sol_tools[0]) tool_specs = [Tool(name="sol")] + from .read_tools import build_read_tools + + # Per-tier gating is later; bounded reads register for every run today. + read_tools = build_read_tools( + journal=journal, + read_call_budget=read_call_budget, + ) + for read_tool in read_tools: + register_tool(read_tool.name, read_tool) + tool_specs.append(Tool(name=read_tool.name)) default_tools = ["FinishTool"] if expects_emit_final: from .emit_final_tool import build_emit_final_tools @@ -821,7 +832,6 @@ async def run_cogitate( system_prompt=system_instruction, ) - journal = Path(get_journal()) persistence_dir = journal / ".cache" / "cogitate-history" / session_id persistence_dir.mkdir(parents=True, exist_ok=True) translator = _OpenHandsTranslator( diff --git a/solstone/think/providers/read_tools.py b/solstone/think/providers/read_tools.py new file mode 100644 index 000000000..961c05e82 --- /dev/null +++ b/solstone/think/providers/read_tools.py @@ -0,0 +1,346 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""OpenHands tool wrappers for bounded cogitate journal reads.""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import Any, cast + +from solstone.think.cogitate_read_tools import ( + ReadBudget, + ReadResult, + glob, + grep_search, + list_directory, + read_file, +) + + +def _invoke_read_file(action: Any, journal: Path, budget: ReadBudget) -> ReadResult: + return read_file( + journal, + action.path, + start_line=action.start_line, + budget=budget, + ) + + +def _invoke_list_directory( + action: Any, + journal: Path, + budget: ReadBudget, +) -> ReadResult: + return list_directory( + journal, + action.path, + recursive=action.recursive, + include_hidden=action.include_hidden, + pattern=action.pattern, + budget=budget, + ) + + +def _invoke_glob(action: Any, journal: Path, budget: ReadBudget) -> ReadResult: + return glob( + journal, + action.pattern, + root=action.root, + include_hidden=action.include_hidden, + budget=budget, + ) + + +def _invoke_grep_search(action: Any, journal: Path, budget: ReadBudget) -> ReadResult: + return grep_search( + journal, + action.pattern, + path=action.path, + regex=action.regex, + case_sensitive=action.case_sensitive, + file_glob=action.file_glob, + context_lines=action.context_lines, + include_hidden=action.include_hidden, + budget=budget, + ) + + +def _render(result: ReadResult) -> str: + if result.tool == read_file.__name__: + text = cast(str, result.payload) + elif result.tool == list_directory.__name__: + entries = cast(Iterable[Any], result.payload) + text = "\n".join( + f"{entry.path}/" if entry.is_dir else entry.path for entry in entries + ) + elif result.tool == glob.__name__: + text = "\n".join(cast(Iterable[str], result.payload)) + elif result.tool == grep_search.__name__: + lines: list[str] = [] + matches = cast(Iterable[Any], result.payload) + for match in matches: + lines.extend(f"{match.path}-{before}" for before in match.before) + lines.append(f"{match.path}:{match.lineno}:{match.line}") + lines.extend(f"{match.path}-{after}" for after in match.after) + text = "\n".join(lines) + else: + raise ValueError(f"Unsupported read result tool: {result.tool}") + + if result.truncated and result.notice: + return f"{text}\n{result.notice}" if text else result.notice + return text + + +# Lazy cache for the openhands-derived Read* classes. The classes have to +# live at module level (i.e. without `` in their __qualname__ and +# discoverable as attributes on this module) because openhands-sdk persists tool +# events to disk and re-validates them via `Event.model_validate_json`, which +# rejects subclasses whose qualname contains "". OpenHands is installed +# on demand, so define the classes lazily and promote them into this module. +_READ_TYPES: dict[str, Any] = {} + + +def _ensure_read_types() -> dict[str, Any]: + if _READ_TYPES: + return _READ_TYPES + + from openhands.sdk.tool import ToolAnnotations, ToolDefinition, ToolExecutor + from openhands.sdk.tool.schema import Action, Observation + from pydantic import Field + + class ReadFileAction(Action): + path: str = Field(description="Journal-root-relative text file path to read.") + start_line: int = Field( + 1, + description="1-based line number to start reading from.", + ) + + class ListDirectoryAction(Action): + path: str = Field(".", description="Journal-root-relative directory path.") + recursive: bool = Field( + False, + description="Walk recursively below the directory.", + ) + include_hidden: bool = Field( + False, + description="Include hidden entries that are not otherwise denied.", + ) + pattern: str | None = Field( + None, + description="Optional fnmatch pattern applied to each entry name.", + ) + + class GlobAction(Action): + pattern: str = Field( + description="Recursive fnmatch pattern over journal-relative paths." + ) + root: str = Field(".", description="Journal-root-relative directory to narrow.") + include_hidden: bool = Field( + False, + description="Include hidden entries that are not otherwise denied.", + ) + + class GrepSearchAction(Action): + pattern: str = Field(description="Literal text or regex pattern to search for.") + path: str = Field(".", description="Journal-root-relative file or directory.") + regex: bool = Field(False, description="Treat pattern as a regular expression.") + case_sensitive: bool = Field( + False, + description="Use case-sensitive matching.", + ) + file_glob: str | None = Field( + None, + description="Optional fnmatch pattern over journal-relative file paths.", + ) + context_lines: int = Field( + 0, + description="Number of surrounding lines to include for each match.", + ) + include_hidden: bool = Field( + False, + description="Include hidden entries that are not otherwise denied.", + ) + + class ReadObservation(Observation): + pass + + class ReadToolExecutor(ToolExecutor): + def __init__( + self, + *, + journal: Path, + budget: ReadBudget, + invoke: Any, + ) -> None: + self.journal = journal + self.budget = budget + self.invoke = invoke + + def __call__(self, action: Any, conversation: Any = None) -> Any: + del conversation + result = self.invoke(action, self.journal, self.budget) + if not result.ok: + return ReadObservation.from_text(result.refusal, is_error=True) + return ReadObservation.from_text(_render(result)) + + class ReadFileTool(ToolDefinition[ReadFileAction, ReadObservation]): + name = read_file.__name__ + + @classmethod + def create(cls, *args: Any, **kwargs: Any) -> list[Any]: + del args, kwargs + return [] + + class ListDirectoryTool(ToolDefinition[ListDirectoryAction, ReadObservation]): + name = list_directory.__name__ + + @classmethod + def create(cls, *args: Any, **kwargs: Any) -> list[Any]: + del args, kwargs + return [] + + class GlobTool(ToolDefinition[GlobAction, ReadObservation]): + name = glob.__name__ + + @classmethod + def create(cls, *args: Any, **kwargs: Any) -> list[Any]: + del args, kwargs + return [] + + class GrepSearchTool(ToolDefinition[GrepSearchAction, ReadObservation]): + name = grep_search.__name__ + + @classmethod + def create(cls, *args: Any, **kwargs: Any) -> list[Any]: + del args, kwargs + return [] + + # Promote the closure-defined classes onto this module so they look + # module-level to openhands-sdk's serialization machinery. Without + # this, `__qualname__` carries `` and re-deserializing tool + # events fails inside stuck_detector with + # "Local classes not supported". + module = sys.modules[__name__] + for cls in ( + ReadFileAction, + ListDirectoryAction, + GlobAction, + GrepSearchAction, + ReadObservation, + ReadToolExecutor, + ReadFileTool, + ListDirectoryTool, + GlobTool, + GrepSearchTool, + ): + cls.__module__ = __name__ + cls.__qualname__ = cls.__name__ + setattr(module, cls.__name__, cls) + + _READ_TYPES.update( + ReadFileAction=ReadFileAction, + ListDirectoryAction=ListDirectoryAction, + GlobAction=GlobAction, + GrepSearchAction=GrepSearchAction, + ReadObservation=ReadObservation, + ReadToolExecutor=ReadToolExecutor, + ReadFileTool=ReadFileTool, + ListDirectoryTool=ListDirectoryTool, + GlobTool=GlobTool, + GrepSearchTool=GrepSearchTool, + ToolAnnotations=ToolAnnotations, + ) + return _READ_TYPES + + +def build_read_tools(*, journal: Path, read_call_budget: int) -> list[Any]: + types = _ensure_read_types() + read_observation = types["ReadObservation"] + read_executor_cls = types["ReadToolExecutor"] + tool_annotations = types["ToolAnnotations"] + budget = ReadBudget(cap=read_call_budget) + + read_file_tool = types["ReadFileTool"]( + description=( + "Bounded UTF-8 journal text read. Paths are journal-root-relative; " + "use start_line to paginate a truncation." + ), + action_type=types["ReadFileAction"], + observation_type=read_observation, + executor=read_executor_cls( + journal=journal, + budget=budget, + invoke=_invoke_read_file, + ), + annotations=tool_annotations( + title=read_file.__name__, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ) + list_directory_tool = types["ListDirectoryTool"]( + description=( + "Journal-root-relative directory listing. Supports recursive walks " + "and fnmatch patterns on entry names." + ), + action_type=types["ListDirectoryAction"], + observation_type=read_observation, + executor=read_executor_cls( + journal=journal, + budget=budget, + invoke=_invoke_list_directory, + ), + annotations=tool_annotations( + title=list_directory.__name__, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ) + glob_tool = types["GlobTool"]( + description=( + "Recursive fnmatch over journal paths where '*' spans '/'; root " + "narrows the search." + ), + action_type=types["GlobAction"], + observation_type=read_observation, + executor=read_executor_cls( + journal=journal, + budget=budget, + invoke=_invoke_glob, + ), + annotations=tool_annotations( + title=glob.__name__, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ) + grep_search_tool = types["GrepSearchTool"]( + description=( + "Literal-or-regex search of journal text files. Narrow with path " + "and file_glob; context_lines adds surrounding lines." + ), + action_type=types["GrepSearchAction"], + observation_type=read_observation, + executor=read_executor_cls( + journal=journal, + budget=budget, + invoke=_invoke_grep_search, + ), + annotations=tool_annotations( + title=grep_search.__name__, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ) + return [read_file_tool, list_directory_tool, glob_tool, grep_search_tool] diff --git a/tests/test_cogitate_contract.py b/tests/test_cogitate_contract.py index 0137f2fab..86ba8bd84 100644 --- a/tests/test_cogitate_contract.py +++ b/tests/test_cogitate_contract.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +from pathlib import Path + from solstone.think.cogitate_contract import ( COGITATE_ACCESS_TIERS, COGITATE_RUNTIME_PREAMBLE, @@ -75,3 +77,24 @@ def test_cogitate_runtime_preamble_content_guard(): assert "through a `sol` domain command" in COGITATE_RUNTIME_PREAMBLE assert "no MCP tools" in COGITATE_RUNTIME_PREAMBLE assert "no bare `journal ...` commands" in COGITATE_RUNTIME_PREAMBLE + assert "read_file" in COGITATE_RUNTIME_PREAMBLE + assert "list_directory" in COGITATE_RUNTIME_PREAMBLE + assert "glob" in COGITATE_RUNTIME_PREAMBLE + assert "grep_search" in COGITATE_RUNTIME_PREAMBLE + assert "when this run provides a read tool" not in COGITATE_RUNTIME_PREAMBLE + + +def test_cogitate_doc_preamble_block_matches_source_constant(): + docs_path = Path(__file__).resolve().parents[1] / "docs" / "COGITATE.md" + text = docs_path.read_text(encoding="utf-8") + heading = "## The in-context preamble (named source constant)" + _, heading_found, tail = text.partition(heading) + assert heading_found + _, verbatim_found, tail = tail.partition("verbatim text:") + assert verbatim_found + _, fence_found, tail = tail.partition("```\n") + assert fence_found + block, closing_fence, _tail = tail.partition("\n```") + assert closing_fence + + assert block == COGITATE_RUNTIME_PREAMBLE.rstrip("\n") diff --git a/tests/test_openhands_provider.py b/tests/test_openhands_provider.py index 0cb07800b..555b818b2 100644 --- a/tests/test_openhands_provider.py +++ b/tests/test_openhands_provider.py @@ -404,7 +404,14 @@ def test_run_cogitate_uses_emit_final_branch_for_output_path( conversation = fake_openhands.Conversation.instances[0] assert result is None - assert [tool.name for tool in conversation.agent.tools] == ["sol", "emit_final"] + assert [tool.name for tool in conversation.agent.tools] == [ + "sol", + "read_file", + "list_directory", + "glob", + "grep_search", + "emit_final", + ] assert conversation.agent.include_default_tools == [] error_events = [event for event in events if event["event"] == "error"] assert len(error_events) == 1 @@ -471,7 +478,13 @@ def test_run_cogitate_keeps_finish_branch_without_output_path( conversation = fake_openhands.Conversation.instances[0] assert result is None - assert [tool.name for tool in conversation.agent.tools] == ["sol"] + assert [tool.name for tool in conversation.agent.tools] == [ + "sol", + "read_file", + "list_directory", + "glob", + "grep_search", + ] assert conversation.agent.include_default_tools == ["FinishTool"] finish_events = [event for event in events if event["event"] == "finish"] assert len(finish_events) == 1 @@ -491,7 +504,14 @@ def test_run_cogitate_uses_emit_final_branch_for_daily_no_output( conversation = fake_openhands.Conversation.instances[0] assert not config.get("output_path") - assert [tool.name for tool in conversation.agent.tools] == ["sol", "emit_final"] + assert [tool.name for tool in conversation.agent.tools] == [ + "sol", + "read_file", + "list_directory", + "glob", + "grep_search", + "emit_final", + ] assert conversation.agent.include_default_tools == [] error_events = [event for event in events if event["event"] == "error"] assert len(error_events) == 1 diff --git a/tests/test_openhands_read_tools.py b/tests/test_openhands_read_tools.py new file mode 100644 index 000000000..719843c67 --- /dev/null +++ b/tests/test_openhands_read_tools.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from solstone.think.cogitate_read_tools import ( + glob, + grep_search, + list_directory, + read_file, +) +from solstone.think.providers import read_tools +from tests.openhands_fakes import install_fake_openhands + + +@pytest.fixture +def fake_openhands(monkeypatch): + return install_fake_openhands(monkeypatch) + + +def _build_journal(tmp_path: Path) -> Path: + journal = tmp_path / "journal" + (journal / "notes").mkdir(parents=True) + (journal / "notes" / "a.txt").write_text("hello x\n", encoding="utf-8") + (journal / ".git").mkdir() + (journal / ".git" / "config").write_text("[core]\n", encoding="utf-8") + (journal / "bin.dat").write_bytes(b"hello\x00x") + (tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8") + (journal / "outside-link.txt").symlink_to(tmp_path / "outside.txt") + return journal + + +def _build_tools(journal: Path, read_call_budget: int = 20) -> dict[str, object]: + read_tools._READ_TYPES.clear() + tools = read_tools.build_read_tools( + journal=journal, + read_call_budget=read_call_budget, + ) + return {tool.name: tool for tool in tools} + + +def _read_file_args(path: str) -> dict[str, object]: + return {"path": path, "start_line": 1} + + +def _list_directory_args(path: str = ".") -> dict[str, object]: + return { + "path": path, + "recursive": False, + "include_hidden": False, + "pattern": None, + } + + +def test_read_tools_names_and_promoted_qualnames(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = read_tools.build_read_tools(journal=journal, read_call_budget=20) + + assert [tool.name for tool in tools] == [ + read_file.__name__, + list_directory.__name__, + glob.__name__, + grep_search.__name__, + ] + for tool in tools: + assert "" not in type(tool).__qualname__ + + +def test_read_file_allowed_returns_file_content(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal) + tool = tools[read_file.__name__] + + observation = tool(tool.action_from_arguments(_read_file_args("notes/a.txt"))) + + assert observation.is_error is False + assert "hello x" in observation.text + + +def test_read_file_denies_blocked_component(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal) + tool = tools[read_file.__name__] + + observation = tool(tool.action_from_arguments(_read_file_args(".git/config"))) + + assert observation.is_error is True + assert observation.text.startswith("denied_component:") + + +def test_read_file_denies_path_escape_outside_journal(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal) + tool = tools[read_file.__name__] + + observation = tool(tool.action_from_arguments(_read_file_args("outside-link.txt"))) + + assert observation.is_error is True + assert observation.text.startswith("path_escape:") + + +def test_read_file_denies_binary_file(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal) + tool = tools[read_file.__name__] + + observation = tool(tool.action_from_arguments(_read_file_args("bin.dat"))) + + assert observation.is_error is True + assert observation.text.startswith("binary_file:") + + +def test_read_tools_share_one_cross_tool_budget(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal, read_call_budget=1) + read_tool = tools[read_file.__name__] + list_tool = tools[list_directory.__name__] + + first = read_tool(read_tool.action_from_arguments(_read_file_args("notes/a.txt"))) + second = list_tool(list_tool.action_from_arguments(_list_directory_args())) + + assert first.is_error is False + assert second.is_error is True + assert second.text.startswith("budget_exhausted:") + + +def test_list_directory_renders_newline_joined_paths(fake_openhands, tmp_path): + journal = _build_journal(tmp_path) + tools = _build_tools(journal) + tool = tools[list_directory.__name__] + + observation = tool(tool.action_from_arguments(_list_directory_args())) + + assert observation.is_error is False + assert "notes/" in observation.text + assert "Entry(" not in observation.text