Skip to content

Commit a064849

Browse files
committed
tools: give the context protocol a tool interface (#257)
We already own a good context protocol — generation-pinned manifest, typed decision packet, per-section queries, hash validation, a hard per-query byte budget, all enforced in code. What we did not own is a tool interface: the model reaches all of it by being told, in markdown prose, to run `python3 /root/.openclaw/workspace/scripts/data/brief_decision_packet.py …`. That is the actual OpenClaw binding. It forces the payload and skill to know internal file layout, makes SKILL.md the only machine-unreadable copy of the interface, and leaves any other runner to re-derive the protocol from prose — which is why gh_action_brief_fallback.py cannot do the lazy queries at all. - clawock/tools/ — a small contract (name, description, JSON-Schema parameters, is_readonly, execute) plus check_available(), a per-workspace registry, and OpenAI/Anthropic schema export. - clawock/tools/context_tools.py — decision_packet_summary, decision_packet_query, context_bundle, report_context. Each delegates to the code that implements it today; none reimplements the protocol. Fixes a real hole while wiring it: the 24 KiB per-query cap lived only in _print_bounded, on the CLI print path, so every non-CLI caller silently bypassed it. Extracted as bounded_payload() and applied by the tool, because the budget is a property of the query, not of stdout. The workspace is passed in rather than resolved from __file__, and each tool loads that workspace's own module — so an installed wheel works against a foreign workspace, and check_available() excludes a tool whose dependencies are absent instead of exploding inside a model turn. Deliberately NOT adopted from Vibe-Trading: their per-turn context assembly with five compaction layers. Our context is precompiled to disk with a manifest so postflight can validate a report against the exact facts the model saw; compaction that silently drops tool results would break that audit chain. Four tests, mutation-verified: a schema that declares a parameter execute() does not accept reds, and bypassing bounded_payload reds. Closes #257
1 parent 4324de6 commit a064849

4 files changed

Lines changed: 433 additions & 2 deletions

File tree

clawock/tools/__init__.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""A tool contract, so the context protocol stops being prose plus a shell.
2+
3+
`skills/daily-deep-brief/SKILL.md` drives a genuinely good lazy-load protocol —
4+
generation-pinned manifest, typed decision packet, per-section queries, hash
5+
validation, a hard per-query byte budget. The protocol is not the problem. The
6+
problem is how the model reaches it: markdown telling it to run
7+
`python3 /root/.openclaw/workspace/scripts/data/brief_decision_packet.py …`.
8+
9+
That makes the skill the only machine-unreadable copy of the interface, forces
10+
the cron payload to know internal file layout, and leaves any non-OpenClaw
11+
runner to re-derive the protocol from prose — which is exactly why
12+
`gh_action_brief_fallback.py` cannot do the lazy queries and degrades to one
13+
chat() call.
14+
15+
The contract here is deliberately small, and modelled on what a function-calling
16+
API actually needs:
17+
18+
name / description / parameters (JSON Schema) / is_readonly / execute()
19+
20+
plus `check_available()`, so a tool whose dependencies are missing is excluded
21+
from the registry rather than exploding inside a model turn.
22+
23+
What this is NOT: a per-turn context assembler. Our context is precompiled to
24+
disk with a manifest so postflight can validate a report against the exact facts
25+
the model saw. Compaction that silently drops tool results would break that
26+
audit chain, so the tools read the precompiled protocol — they do not replace it.
27+
"""
28+
from __future__ import annotations
29+
30+
import json
31+
from typing import Any
32+
33+
34+
class ToolError(RuntimeError):
35+
"""A tool refused. The message is shown to the caller verbatim."""
36+
37+
38+
class BaseTool:
39+
"""One callable capability, described well enough for an LLM to use it."""
40+
41+
name: str = ""
42+
description: str = ""
43+
parameters: dict[str, Any] = {}
44+
# Read-only tools are safe to run speculatively or in parallel. Anything that
45+
# writes must say so, because the caller's batching depends on it.
46+
is_readonly: bool = True
47+
48+
@classmethod
49+
def check_available(cls, workspace) -> bool:
50+
"""Whether this tool can run against `workspace`.
51+
52+
Returning False excludes it from the registry, which is far better than
53+
surfacing a missing-file traceback in the middle of a model turn.
54+
"""
55+
return True
56+
57+
def execute(self, workspace, **kwargs: Any) -> str:
58+
raise NotImplementedError
59+
60+
# ── schema export ───────────────────────────────────────────────────────
61+
62+
def to_openai_schema(self) -> dict[str, Any]:
63+
return {
64+
"type": "function",
65+
"function": {
66+
"name": self.name,
67+
"description": self.description.strip(),
68+
"parameters": self.parameters or {
69+
"type": "object", "properties": {}, "required": []},
70+
},
71+
}
72+
73+
def to_anthropic_schema(self) -> dict[str, Any]:
74+
return {
75+
"name": self.name,
76+
"description": self.description.strip(),
77+
"input_schema": self.parameters or {
78+
"type": "object", "properties": {}, "required": []},
79+
}
80+
81+
82+
class ToolRegistry:
83+
"""The tools available for one workspace."""
84+
85+
def __init__(self, workspace):
86+
self.workspace = workspace
87+
self._tools: dict[str, BaseTool] = {}
88+
89+
def register(self, tool: BaseTool) -> bool:
90+
if not tool.name:
91+
raise ValueError("a tool must have a name")
92+
if not type(tool).check_available(self.workspace):
93+
return False
94+
self._tools[tool.name] = tool
95+
return True
96+
97+
def __contains__(self, name: str) -> bool:
98+
return name in self._tools
99+
100+
def __len__(self) -> int:
101+
return len(self._tools)
102+
103+
def names(self) -> list[str]:
104+
return sorted(self._tools)
105+
106+
def get(self, name: str) -> BaseTool:
107+
if name not in self._tools:
108+
raise ToolError(f"unknown tool: {name}")
109+
return self._tools[name]
110+
111+
def call(self, name: str, **kwargs: Any) -> str:
112+
return self.get(name).execute(self.workspace, **kwargs)
113+
114+
def schemas(self, dialect: str = "openai") -> list[dict[str, Any]]:
115+
render = {"openai": lambda t: t.to_openai_schema(),
116+
"anthropic": lambda t: t.to_anthropic_schema()}[dialect]
117+
return [render(self._tools[name]) for name in self.names()]
118+
119+
120+
def build_registry(workspace, tools=None) -> ToolRegistry:
121+
"""Registry for a workspace, skipping tools whose dependencies are absent."""
122+
from clawock.tools import context_tools
123+
124+
registry = ToolRegistry(workspace)
125+
for tool in (tools if tools is not None else context_tools.TOOLS):
126+
registry.register(tool() if isinstance(tool, type) else tool)
127+
return registry
128+
129+
130+
def describe(workspace, dialect: str = "openai") -> str:
131+
"""The tool contract as JSON — what a runner hands to a model."""
132+
return json.dumps(build_registry(workspace).schemas(dialect),
133+
ensure_ascii=False, indent=2)

clawock/tools/context_tools.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""The four capabilities the brief skill already drives, given a schema.
2+
3+
Each one delegates to the code that implements it today — `read_packet`,
4+
`summary_view`, `bounded_payload` in `brief_decision_packet.py`. Nothing here
5+
reimplements the protocol; it exposes it. That is deliberate: the generation
6+
pin, the hash check and the per-query byte budget are load-bearing and already
7+
tested, and a second copy of them would be a second thing to get wrong.
8+
9+
The workspace is passed in rather than resolved from `__file__`, because these
10+
tools have to work against a foreign workspace — that is the whole point.
11+
"""
12+
from __future__ import annotations
13+
14+
import importlib.util
15+
import json
16+
import sys
17+
from pathlib import Path
18+
19+
from clawock.tools import BaseTool, ToolError
20+
21+
SECTIONS = ("facts", "technical", "quant", "sentiment", "evidence", "risk",
22+
"constraints")
23+
24+
25+
def _load(workspace, module: str):
26+
"""Import a workspace's own data module.
27+
28+
Not a subprocess, and not a copy: these tools read *that workspace's*
29+
protocol, so importing that workspace's implementation is the honest
30+
resolution. A wheel installed elsewhere still works — it just needs a
31+
workspace to point at, exactly like `portfolio.json`.
32+
"""
33+
path = Path(workspace) / "scripts" / "data" / f"{module}.py"
34+
if not path.exists():
35+
raise ToolError(f"{module}.py not found in workspace {workspace}")
36+
data_dir = str(path.parent)
37+
if data_dir not in sys.path:
38+
sys.path.insert(0, data_dir)
39+
spec = importlib.util.spec_from_file_location(module, path)
40+
loaded = importlib.util.module_from_spec(spec)
41+
spec.loader.exec_module(loaded)
42+
return loaded
43+
44+
45+
def _manifest(workspace, manifest) -> Path:
46+
path = Path(manifest)
47+
if not path.is_absolute():
48+
path = Path(workspace) / path
49+
if not path.exists():
50+
raise ToolError(f"manifest not found: {path}")
51+
return path
52+
53+
54+
class _PacketTool(BaseTool):
55+
@classmethod
56+
def check_available(cls, workspace) -> bool:
57+
return (Path(workspace) / "scripts" / "data"
58+
/ "brief_decision_packet.py").exists()
59+
60+
61+
class DecisionPacketSummary(_PacketTool):
62+
name = "decision_packet_summary"
63+
description = (
64+
"The brief's resident input: book and concentration, per-ticker "
65+
"deterministic status, technical and factor availability, risk counts, "
66+
"allowed actions and evidence IDs. Read this first; query individual "
67+
"tickers only when analysing them."
68+
)
69+
parameters = {
70+
"type": "object",
71+
"properties": {
72+
"manifest": {
73+
"type": "string",
74+
"description": "Path to the generation's manifest.json.",
75+
},
76+
},
77+
"required": ["manifest"],
78+
}
79+
80+
def execute(self, workspace, *, manifest: str) -> str:
81+
packet_module = _load(workspace, "brief_decision_packet")
82+
packet = packet_module.read_packet(_manifest(workspace, manifest))
83+
return packet_module.bounded_payload(packet_module.summary_view(packet))
84+
85+
86+
class DecisionPacketQuery(_PacketTool):
87+
name = "decision_packet_query"
88+
description = (
89+
"One ticker's slice of the decision packet, optionally narrowed to a "
90+
"single section. Prefer a section: whole-ticker queries are larger and "
91+
"the per-query budget is enforced."
92+
)
93+
parameters = {
94+
"type": "object",
95+
"properties": {
96+
"manifest": {"type": "string",
97+
"description": "Path to the generation's manifest.json."},
98+
"ticker": {"type": "string", "description": "Ticker, e.g. 00100."},
99+
"section": {"type": "string", "enum": list(SECTIONS),
100+
"description": "Narrow to one dimension."},
101+
},
102+
"required": ["manifest", "ticker"],
103+
}
104+
105+
def execute(self, workspace, *, manifest: str, ticker: str,
106+
section: str | None = None) -> str:
107+
if section is not None and section not in SECTIONS:
108+
raise ToolError(
109+
f"unknown section {section!r}; expected one of {', '.join(SECTIONS)}")
110+
packet_module = _load(workspace, "brief_decision_packet")
111+
packet = packet_module.read_packet(_manifest(workspace, manifest))
112+
value = (packet.get("tickers") or {}).get(str(ticker))
113+
if value is None:
114+
raise ToolError(f"unknown ticker: {ticker}")
115+
payload = value if section is None else {
116+
"ticker": str(ticker), section: value.get(section)}
117+
# The budget is applied here, not on a print path — that is the bug this
118+
# layer exists to close: every non-CLI caller used to bypass the cap.
119+
return packet_module.bounded_payload(payload)
120+
121+
122+
class ContextBundle(BaseTool):
123+
name = "context_bundle"
124+
description = (
125+
"An audit bundle from the same generation — deep detail that is not in "
126+
"the packet. Load at most one per consumer, immediately before use; this "
127+
"is not default model input."
128+
)
129+
parameters = {
130+
"type": "object",
131+
"properties": {
132+
"manifest": {"type": "string",
133+
"description": "Path to the generation's manifest.json."},
134+
"bundle": {"type": "string",
135+
"description": "Bundle name as listed in the manifest."},
136+
},
137+
"required": ["manifest", "bundle"],
138+
}
139+
140+
@classmethod
141+
def check_available(cls, workspace) -> bool:
142+
return (Path(workspace) / "scripts" / "data" / "brief_context.py").exists()
143+
144+
def execute(self, workspace, *, manifest: str, bundle: str) -> str:
145+
path = _manifest(workspace, manifest)
146+
entry = ((json.loads(path.read_text(encoding="utf-8")).get("artifacts") or {})
147+
.get(bundle))
148+
if not entry:
149+
available = sorted(
150+
(json.loads(path.read_text(encoding="utf-8")).get("artifacts") or {}))
151+
raise ToolError(
152+
f"unknown bundle {bundle!r}; manifest lists: {', '.join(available)}")
153+
target = Path(entry.get("path") or "")
154+
if not target.exists():
155+
raise ToolError(f"bundle {bundle!r} is listed but missing: {target}")
156+
return target.read_text(encoding="utf-8")
157+
158+
159+
class ReportContext(BaseTool):
160+
name = "report_context"
161+
description = (
162+
"The deterministic context for one market report slot: the title and the "
163+
"harness-owned data block that will be prepended to the prose."
164+
)
165+
parameters = {
166+
"type": "object",
167+
"properties": {
168+
"market": {"type": "string", "enum": ["hk", "us"]},
169+
"phase": {"type": "string",
170+
"description": "Slot, e.g. open / mid / pm / close."},
171+
"date": {"type": "string", "description": "YYYY-MM-DD."},
172+
},
173+
"required": ["market", "phase", "date"],
174+
}
175+
176+
@classmethod
177+
def check_available(cls, workspace) -> bool:
178+
return (Path(workspace) / "memory" / ".tmp").exists()
179+
180+
def execute(self, workspace, *, market: str, phase: str, date: str) -> str:
181+
path = (Path(workspace) / "memory" / ".tmp"
182+
/ f"report-context-{market}-{phase}-{date}.json")
183+
if not path.exists():
184+
raise ToolError(f"no report context for {market}/{phase} on {date}")
185+
return path.read_text(encoding="utf-8")
186+
187+
188+
TOOLS = (DecisionPacketSummary, DecisionPacketQuery, ContextBundle, ReportContext)

scripts/data/brief_decision_packet.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,12 +693,23 @@ def read_packet(manifest_path: Path) -> dict:
693693
return packet
694694

695695

696-
def _print_bounded(value) -> None:
696+
def bounded_payload(value) -> str:
697+
"""Serialise a query result, refusing anything over the per-query cap.
698+
699+
The cap used to live only on the print path, so any caller that used
700+
read_packet()/summary_view() directly — every non-CLI consumer, including the
701+
tool layer — silently bypassed it. The budget is a property of the query, not
702+
of stdout.
703+
"""
697704
text = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
698705
size = len(text.encode("utf-8"))
699706
if size > MAX_QUERY_BYTES:
700707
raise ValueError(f"decision packet query exceeds {MAX_QUERY_BYTES} bytes: {size}")
701-
print(text, end="")
708+
return text
709+
710+
711+
def _print_bounded(value) -> None:
712+
print(bounded_payload(value), end="")
702713

703714

704715
def main() -> int:

0 commit comments

Comments
 (0)