halo-format-langgraph 0.2.0__tar.gz
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.
- halo_format_langgraph-0.2.0/.gitignore +41 -0
- halo_format_langgraph-0.2.0/PKG-INFO +46 -0
- halo_format_langgraph-0.2.0/README.md +32 -0
- halo_format_langgraph-0.2.0/pyproject.toml +27 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/__init__.py +132 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/accumulate.py +55 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/constants.py +16 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/middleware.py +102 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/nav_tool.py +50 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/serialize.py +182 -0
- halo_format_langgraph-0.2.0/src/halo_format_langgraph/session.py +196 -0
- halo_format_langgraph-0.2.0/tests/test_graph.py +70 -0
- halo_format_langgraph-0.2.0/tests/test_install.py +50 -0
- halo_format_langgraph-0.2.0/tests/test_middleware.py +101 -0
- halo_format_langgraph-0.2.0/tests/test_nav_tool.py +66 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Node / TypeScript
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
.npm/
|
|
6
|
+
pnpm-debug.log*
|
|
7
|
+
npm-debug.log*
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.py[cod]
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.eggs/
|
|
14
|
+
build/
|
|
15
|
+
*.whl
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
.uv/
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
|
|
23
|
+
# Coverage / test output
|
|
24
|
+
coverage/
|
|
25
|
+
.coverage
|
|
26
|
+
htmlcov/
|
|
27
|
+
|
|
28
|
+
# Release: Python distributions built in CI before the PyPI upload
|
|
29
|
+
dist-python/
|
|
30
|
+
|
|
31
|
+
# Editor / OS
|
|
32
|
+
.DS_Store
|
|
33
|
+
.idea/
|
|
34
|
+
*.swp
|
|
35
|
+
|
|
36
|
+
# Local env
|
|
37
|
+
.env
|
|
38
|
+
.env.local
|
|
39
|
+
|
|
40
|
+
# Internal design docs — never commit
|
|
41
|
+
private/
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: halo-format-langgraph
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Halo host adapter for LangChain agents and LangGraph graphs (Python): a wrap_tool_call encode middleware plus a halo_fetch navigation tool. install_halo() wires it in one call.
|
|
5
|
+
Project-URL: Homepage, https://github.com/halo-format/halo
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: halo-format
|
|
9
|
+
Requires-Dist: langchain-core>=1.0
|
|
10
|
+
Requires-Dist: langchain>=1.0
|
|
11
|
+
Provides-Extra: langgraph
|
|
12
|
+
Requires-Dist: langgraph; extra == 'langgraph'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# halo-format-langgraph
|
|
16
|
+
|
|
17
|
+
Halo host adapter for [LangChain](https://github.com/langchain-ai/langchain) agents and
|
|
18
|
+
[LangGraph](https://github.com/langchain-ai/langgraph) graphs (Python). `install_halo()` wires it in
|
|
19
|
+
one call:
|
|
20
|
+
|
|
21
|
+
- a **`wrap_tool_call` encode middleware** — the deterministic wrap-the-tool-call hook, LangChain's
|
|
22
|
+
analog of the Claude SDK's PostToolUse — that replaces a large tool result with a halo **shape map**
|
|
23
|
+
(root kind + one line per field: ref, kind, and a bounded preview), so the payload stays out of the
|
|
24
|
+
model's context while it still sees what's there. The full envelope rides in the `ToolMessage`
|
|
25
|
+
**`artifact`** (kept in graph state, never sent to the model) for audit/replay;
|
|
26
|
+
- a single plain LangChain **`halo_fetch`** tool the model uses to pull back only the leaves it needs,
|
|
27
|
+
verified on read — a ref that lands on a branch returns that branch's sub-refs, so one batch API both
|
|
28
|
+
pulls and expands (there is no separate `halo_walk`).
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from langchain.agents import create_agent
|
|
32
|
+
from halo_format_langgraph import install_halo
|
|
33
|
+
|
|
34
|
+
result = install_halo(tools=my_tools)
|
|
35
|
+
agent = create_agent(model, tools=result.tools, middleware=result.middleware)
|
|
36
|
+
# result.session holds the shared store for audit/inspection
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For hand-built `StateGraph`s, use `halo_tool_node(tools=...)` instead — it returns a `ToolNode` with the
|
|
40
|
+
same wrapper attached as `wrap_tool_call` / `awrap_tool_call`, plus the tools list (to bind to your
|
|
41
|
+
model) and the session.
|
|
42
|
+
|
|
43
|
+
The middleware is deterministic plumbing (it always fires, for every tool); the Halo Skill (or
|
|
44
|
+
prompt-mode guidance) is the navigation behavior. Pass `store=FileStore(dir)` for the heavy/persistent
|
|
45
|
+
deployment. The core engine is [`halo-format`](https://pypi.org/project/halo-format/); this package is
|
|
46
|
+
only the shim.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# halo-format-langgraph
|
|
2
|
+
|
|
3
|
+
Halo host adapter for [LangChain](https://github.com/langchain-ai/langchain) agents and
|
|
4
|
+
[LangGraph](https://github.com/langchain-ai/langgraph) graphs (Python). `install_halo()` wires it in
|
|
5
|
+
one call:
|
|
6
|
+
|
|
7
|
+
- a **`wrap_tool_call` encode middleware** — the deterministic wrap-the-tool-call hook, LangChain's
|
|
8
|
+
analog of the Claude SDK's PostToolUse — that replaces a large tool result with a halo **shape map**
|
|
9
|
+
(root kind + one line per field: ref, kind, and a bounded preview), so the payload stays out of the
|
|
10
|
+
model's context while it still sees what's there. The full envelope rides in the `ToolMessage`
|
|
11
|
+
**`artifact`** (kept in graph state, never sent to the model) for audit/replay;
|
|
12
|
+
- a single plain LangChain **`halo_fetch`** tool the model uses to pull back only the leaves it needs,
|
|
13
|
+
verified on read — a ref that lands on a branch returns that branch's sub-refs, so one batch API both
|
|
14
|
+
pulls and expands (there is no separate `halo_walk`).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from langchain.agents import create_agent
|
|
18
|
+
from halo_format_langgraph import install_halo
|
|
19
|
+
|
|
20
|
+
result = install_halo(tools=my_tools)
|
|
21
|
+
agent = create_agent(model, tools=result.tools, middleware=result.middleware)
|
|
22
|
+
# result.session holds the shared store for audit/inspection
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For hand-built `StateGraph`s, use `halo_tool_node(tools=...)` instead — it returns a `ToolNode` with the
|
|
26
|
+
same wrapper attached as `wrap_tool_call` / `awrap_tool_call`, plus the tools list (to bind to your
|
|
27
|
+
model) and the session.
|
|
28
|
+
|
|
29
|
+
The middleware is deterministic plumbing (it always fires, for every tool); the Halo Skill (or
|
|
30
|
+
prompt-mode guidance) is the navigation behavior. Pass `store=FileStore(dir)` for the heavy/persistent
|
|
31
|
+
deployment. The core engine is [`halo-format`](https://pypi.org/project/halo-format/); this package is
|
|
32
|
+
only the shim.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "halo-format-langgraph"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Halo host adapter for LangChain agents and LangGraph graphs (Python): a wrap_tool_call encode middleware plus a halo_fetch navigation tool. install_halo() wires it in one call."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
# The adapter is a separate opt-in package; depending on the host framework the consumer already has is
|
|
9
|
+
# correct. The "lightest install" principle is about the core (halo-format), which stays zero-dep.
|
|
10
|
+
# langgraph is needed only for the halo_tool_node() path and is imported lazily there.
|
|
11
|
+
dependencies = ["halo-format", "langchain>=1.0", "langchain-core>=1.0"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
langgraph = ["langgraph"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/halo-format/halo"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/halo_format_langgraph"]
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
halo-format = { workspace = true }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""halo_format_langgraph — Halo host adapter for LangChain agents and LangGraph graphs (Python).
|
|
2
|
+
|
|
3
|
+
The interception point is LangChain's ``wrap_tool_call`` — a deterministic wrap-the-tool-call hook, the
|
|
4
|
+
direct analog of the Claude Agent SDK's PostToolUse. Above a size threshold it encodes a tool result
|
|
5
|
+
into a shared store and rewrites the ToolMessage the model sees: ``content`` becomes a SHAPE MAP (not the
|
|
6
|
+
blob), and the full envelope rides in ``artifact`` (kept in graph state, never sent to the model). One
|
|
7
|
+
plain LangChain tool, ``halo_fetch(refs)``, backed by the same store, lets the model pull back the
|
|
8
|
+
withheld leaves (and expand branches), verified on read.
|
|
9
|
+
|
|
10
|
+
Two attach surfaces, one wrapper body:
|
|
11
|
+
|
|
12
|
+
- ``install_halo(tools=..., middleware=...)`` — the primary path. Returns the augmented ``tools`` and
|
|
13
|
+
``middleware`` lists to splice into ``create_agent`` (plus the shared session). The Halo middleware
|
|
14
|
+
is a ``HaloMiddleware`` (an ``AgentMiddleware`` subclass) providing both sync and async wrap.
|
|
15
|
+
- ``halo_tool_node(tools=...)`` — for hand-built ``StateGraph``s. Returns a ``ToolNode`` with the same
|
|
16
|
+
wrapper attached as ``wrap_tool_call`` / ``awrap_tool_call`` (plus the tools list and session).
|
|
17
|
+
|
|
18
|
+
MANDATORY correctness rule: the middleware must NOT re-encode the navigation tool's own output, or a
|
|
19
|
+
fetched leaf would be replaced by a new map and the model would never see the value. Enforced by an
|
|
20
|
+
``is_halo_nav_tool`` name check at the top of the wrapper.
|
|
21
|
+
|
|
22
|
+
Light vs. heavy (integration doc, Section 7): pass ``store=FileStore(dir)`` for cross-process
|
|
23
|
+
persistence, and wrap navigation with the L2 chain, without touching the control flow here."""
|
|
24
|
+
|
|
25
|
+
from typing import NamedTuple, Optional
|
|
26
|
+
|
|
27
|
+
from .accumulate import KeyOf, arg_join
|
|
28
|
+
from .constants import HALO_FETCH_TOOL, is_halo_nav_tool
|
|
29
|
+
from .middleware import (
|
|
30
|
+
HaloMiddleware,
|
|
31
|
+
make_async_wrapper,
|
|
32
|
+
make_sync_wrapper,
|
|
33
|
+
)
|
|
34
|
+
from .nav_tool import create_halo_fetch_tool, halo_fetch
|
|
35
|
+
from .serialize import parse_tool_output, preview_of, serialize_envelope, shape_of, size_of
|
|
36
|
+
from .session import HaloSession
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InstallHaloResult(NamedTuple):
|
|
40
|
+
tools: list # your tools + halo_fetch, to pass to create_agent(tools=...)
|
|
41
|
+
middleware: list # your middleware + HaloMiddleware, to pass to create_agent(middleware=...)
|
|
42
|
+
session: HaloSession # the shared session (store + map registry), for audit swaps / inspection
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HaloToolNodeResult(NamedTuple):
|
|
46
|
+
tool_node: object # a langgraph ToolNode with the Halo wrapper attached
|
|
47
|
+
tools: list # your tools + halo_fetch, to bind to the model so it can call halo_fetch
|
|
48
|
+
session: HaloSession
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _make_session(store, key_of, alg, now, session) -> HaloSession:
|
|
52
|
+
return session if session is not None else HaloSession(
|
|
53
|
+
store=store, key_of=key_of, alg=alg, now=now
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_halo(
|
|
58
|
+
*,
|
|
59
|
+
tools: Optional[list] = None,
|
|
60
|
+
middleware: Optional[list] = None,
|
|
61
|
+
threshold: int = 2048,
|
|
62
|
+
store=None,
|
|
63
|
+
key_of: Optional[KeyOf] = None,
|
|
64
|
+
alg: str = "sha256",
|
|
65
|
+
now=None,
|
|
66
|
+
session: Optional[HaloSession] = None,
|
|
67
|
+
) -> InstallHaloResult:
|
|
68
|
+
"""Wire the Halo adapter into a ``create_agent`` setup. Append the returned ``tools`` and
|
|
69
|
+
``middleware`` to the call:
|
|
70
|
+
|
|
71
|
+
result = install_halo(tools=my_tools)
|
|
72
|
+
agent = create_agent(model, tools=result.tools, middleware=result.middleware)
|
|
73
|
+
"""
|
|
74
|
+
session = _make_session(store, key_of, alg, now, session)
|
|
75
|
+
mw = HaloMiddleware(session, threshold)
|
|
76
|
+
fetch_tool = create_halo_fetch_tool(session)
|
|
77
|
+
return InstallHaloResult(
|
|
78
|
+
tools=[*(tools or []), fetch_tool],
|
|
79
|
+
middleware=[*(middleware or []), mw],
|
|
80
|
+
session=session,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def halo_tool_node(
|
|
85
|
+
tools: Optional[list] = None,
|
|
86
|
+
*,
|
|
87
|
+
threshold: int = 2048,
|
|
88
|
+
store=None,
|
|
89
|
+
key_of: Optional[KeyOf] = None,
|
|
90
|
+
alg: str = "sha256",
|
|
91
|
+
now=None,
|
|
92
|
+
session: Optional[HaloSession] = None,
|
|
93
|
+
) -> HaloToolNodeResult:
|
|
94
|
+
"""Build a LangGraph ``ToolNode`` with the Halo wrapper attached, for hand-built ``StateGraph``s.
|
|
95
|
+
|
|
96
|
+
Returns the node, the tools list (yours + ``halo_fetch``, to bind to the model), and the session.
|
|
97
|
+
Import is deferred so that ``langgraph`` is only required for this path, not for ``install_halo``.
|
|
98
|
+
"""
|
|
99
|
+
from langgraph.prebuilt import ToolNode
|
|
100
|
+
|
|
101
|
+
session = _make_session(store, key_of, alg, now, session)
|
|
102
|
+
fetch_tool = create_halo_fetch_tool(session)
|
|
103
|
+
all_tools = [*(tools or []), fetch_tool]
|
|
104
|
+
node = ToolNode(
|
|
105
|
+
all_tools,
|
|
106
|
+
wrap_tool_call=make_sync_wrapper(session, threshold),
|
|
107
|
+
awrap_tool_call=make_async_wrapper(session, threshold),
|
|
108
|
+
)
|
|
109
|
+
return HaloToolNodeResult(tool_node=node, tools=all_tools, session=session)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"install_halo",
|
|
114
|
+
"InstallHaloResult",
|
|
115
|
+
"halo_tool_node",
|
|
116
|
+
"HaloToolNodeResult",
|
|
117
|
+
"HaloMiddleware",
|
|
118
|
+
"make_sync_wrapper",
|
|
119
|
+
"make_async_wrapper",
|
|
120
|
+
"create_halo_fetch_tool",
|
|
121
|
+
"halo_fetch",
|
|
122
|
+
"HaloSession",
|
|
123
|
+
"arg_join",
|
|
124
|
+
"KeyOf",
|
|
125
|
+
"size_of",
|
|
126
|
+
"parse_tool_output",
|
|
127
|
+
"serialize_envelope",
|
|
128
|
+
"shape_of",
|
|
129
|
+
"preview_of",
|
|
130
|
+
"HALO_FETCH_TOOL",
|
|
131
|
+
"is_halo_nav_tool",
|
|
132
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Entity accumulation policy: which calls fold into one growing map (SDK architecture, Section 9.1).
|
|
2
|
+
|
|
3
|
+
key_of(tool_name, tool_input) returns an entity key, or None to give the call its own fresh map. The
|
|
4
|
+
default, arg_join, keys on the first scalar argument value, so normalized REST detail calls about one
|
|
5
|
+
entity group together: get_customer({"id": 123}) and get_appointments({"customer_id": 123}) both key
|
|
6
|
+
on 123 and fold into one map, while search({"query": "smith"}) keys on "smith" and stays its own list
|
|
7
|
+
map until the first detail call. It keys on the argument VALUE, not the field name or any result id,
|
|
8
|
+
which is what lets a list result land under the entity instead of fragmenting.
|
|
9
|
+
|
|
10
|
+
arg_join's one real gap is value collision across entity types (customer 123 vs invoice 123, both
|
|
11
|
+
passing 123). That is the single thing a generic heuristic cannot resolve alone; supply a domain
|
|
12
|
+
key_of (e.g. one that prefixes the resource type) when it matters, as it usually does for money or
|
|
13
|
+
medical data."""
|
|
14
|
+
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
KeyOf = Callable[[str, object], Optional[str]]
|
|
18
|
+
|
|
19
|
+
# A short scalar (number, or string up to this many chars) is treated as an id-like argument worth
|
|
20
|
+
# grouping on. Longer strings are typically free text (a query, a body) and are skipped.
|
|
21
|
+
_MAX_KEY_STRING_LENGTH = 128
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _scalar_key(value) -> Optional[str]:
|
|
25
|
+
# bool is an int subclass in Python; exclude it explicitly (a flag is not an id).
|
|
26
|
+
if isinstance(value, bool):
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(value, (int, float)):
|
|
29
|
+
return str(value)
|
|
30
|
+
if isinstance(value, str) and 0 < len(value) <= _MAX_KEY_STRING_LENGTH:
|
|
31
|
+
return value
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def arg_join(tool_name: str, tool_input: object) -> Optional[str]:
|
|
36
|
+
"""Default policy: group by the first scalar argument value.
|
|
37
|
+
|
|
38
|
+
Dicts are scanned in insertion order, lists in index order; a bare scalar input is its own key.
|
|
39
|
+
Returns None when no scalar argument is present (e.g. a no-arg call), so that call gets a fresh
|
|
40
|
+
synthetic map id.
|
|
41
|
+
"""
|
|
42
|
+
direct = _scalar_key(tool_input)
|
|
43
|
+
if direct is not None:
|
|
44
|
+
return direct
|
|
45
|
+
if isinstance(tool_input, dict):
|
|
46
|
+
for value in tool_input.values():
|
|
47
|
+
k = _scalar_key(value)
|
|
48
|
+
if k is not None:
|
|
49
|
+
return k
|
|
50
|
+
elif isinstance(tool_input, (list, tuple)):
|
|
51
|
+
for item in tool_input:
|
|
52
|
+
k = _scalar_key(item)
|
|
53
|
+
if k is not None:
|
|
54
|
+
return k
|
|
55
|
+
return None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The one name shared between the middleware (which must exclude it) and the navigation tool (which
|
|
2
|
+
defines it). Kept in one place so the exclusion and the registration can never drift apart.
|
|
3
|
+
|
|
4
|
+
Unlike the Claude adapter, the navigation tool here is a plain LangChain tool, not an in-process MCP
|
|
5
|
+
server, so its name has no ``mcp__<server>__`` prefix — it is simply ``halo_fetch`` and the exclusion
|
|
6
|
+
is a single string comparison."""
|
|
7
|
+
|
|
8
|
+
# One navigation tool only. halo_fetch is the single batch API the model ever sees: it pulls leaves
|
|
9
|
+
# AND, when a ref lands on a branch, returns that branch's sub-shape — so there is no second tool
|
|
10
|
+
# (no halo_walk) to choose between or confuse a leaf ref with.
|
|
11
|
+
HALO_FETCH_TOOL = "halo_fetch"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_halo_nav_tool(tool_name: str) -> bool:
|
|
15
|
+
"""True for the adapter's own navigation tool — the result the middleware must NOT re-encode."""
|
|
16
|
+
return tool_name == HALO_FETCH_TOOL
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The encode middleware — the plumbing. It wraps every tool call: it runs the tool via the handler,
|
|
2
|
+
and above a size threshold rewrites the resulting ToolMessage so the model sees a SHAPE MAP instead of
|
|
3
|
+
the blob, while the full payload goes to the store and the full envelope rides in ``ToolMessage.artifact``
|
|
4
|
+
(kept in graph state, never serialized into the model's context). This is deterministic and
|
|
5
|
+
model-independent: the payload is out of context before the model is involved, so the model cannot leak
|
|
6
|
+
a full result by forgetting to navigate.
|
|
7
|
+
|
|
8
|
+
The same wrapper body drives two LangChain/LangGraph surfaces, which share one ``ToolCallWrapper`` shape
|
|
9
|
+
``(request, handler/execute) -> ToolMessage | Command``:
|
|
10
|
+
|
|
11
|
+
- ``create_agent(middleware=[...])`` — via the ``HaloMiddleware`` subclass below (the primary path);
|
|
12
|
+
- ``ToolNode(wrap_tool_call=..., awrap_tool_call=...)`` — via the bare functions, for hand-built graphs.
|
|
13
|
+
|
|
14
|
+
Two passthrough conditions, in order (mirrors the Claude encode hook):
|
|
15
|
+
1. The tool IS the halo navigation tool. MANDATORY: re-encoding a fetched leaf would replace it with
|
|
16
|
+
a new map and the model would never receive the value.
|
|
17
|
+
2. The ToolMessage content is below the size threshold, or is not JSON-ish — pass through untouched.
|
|
18
|
+
|
|
19
|
+
A non-``ToolMessage`` return (a ``Command`` that updates graph state directly) always passes through:
|
|
20
|
+
there is nothing to encode.
|
|
21
|
+
|
|
22
|
+
The session underneath is SYNC (port convention); only the async surface awaits the handler."""
|
|
23
|
+
|
|
24
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
25
|
+
from langchain_core.messages import ToolMessage
|
|
26
|
+
|
|
27
|
+
from .constants import is_halo_nav_tool
|
|
28
|
+
from .serialize import parse_tool_output, serialize_envelope, size_of
|
|
29
|
+
|
|
30
|
+
_DEFAULT_THRESHOLD = 2048
|
|
31
|
+
|
|
32
|
+
# Where an upstream tool's own artifact is preserved if Halo needs the slot for its envelope, so a
|
|
33
|
+
# tool that already used content_and_artifact does not silently lose its data.
|
|
34
|
+
_PRIOR_ARTIFACT_KEY = "halo_prior_artifact"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rewrite(session, threshold: int, msg, request):
|
|
38
|
+
"""Encode a tool's ToolMessage and return a rewritten copy: content -> shape map, artifact ->
|
|
39
|
+
envelope. Returns the message untouched when it should pass through. Builds a copy via
|
|
40
|
+
``model_copy`` rather than mutating in place, so a handler that returns a shared/cached message is
|
|
41
|
+
never clobbered (the safe path — the handler's tool_call_id, name and status are preserved)."""
|
|
42
|
+
if not isinstance(msg, ToolMessage):
|
|
43
|
+
return msg # a Command (state update) or other return — nothing to encode
|
|
44
|
+
if size_of(msg.content) < threshold:
|
|
45
|
+
return msg
|
|
46
|
+
value = parse_tool_output(msg.content)
|
|
47
|
+
if value is None:
|
|
48
|
+
return msg
|
|
49
|
+
|
|
50
|
+
tool_call = request.tool_call
|
|
51
|
+
result = session.ingest(tool_call["name"], tool_call.get("args"), value)
|
|
52
|
+
hints = session.describe(result["envelope"])
|
|
53
|
+
|
|
54
|
+
update = {
|
|
55
|
+
"content": serialize_envelope(result["envelope"], hints), # the SHAPE MAP (model-visible)
|
|
56
|
+
"artifact": result["envelope"], # full envelope (out of context, persisted in state)
|
|
57
|
+
}
|
|
58
|
+
if msg.artifact is not None:
|
|
59
|
+
# Preserve an upstream content_and_artifact tool's own artifact rather than dropping it.
|
|
60
|
+
update["additional_kwargs"] = {
|
|
61
|
+
**(msg.additional_kwargs or {}),
|
|
62
|
+
_PRIOR_ARTIFACT_KEY: msg.artifact,
|
|
63
|
+
}
|
|
64
|
+
return msg.model_copy(update=update)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def make_sync_wrapper(session, threshold: int = _DEFAULT_THRESHOLD):
|
|
68
|
+
"""The sync ToolCallWrapper for ToolNode(wrap_tool_call=...) and the middleware's sync path."""
|
|
69
|
+
|
|
70
|
+
def wrapper(request, handler):
|
|
71
|
+
if is_halo_nav_tool(request.tool_call["name"]):
|
|
72
|
+
return handler(request) # MANDATORY: never re-encode a fetched leaf
|
|
73
|
+
return _rewrite(session, threshold, handler(request), request)
|
|
74
|
+
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_async_wrapper(session, threshold: int = _DEFAULT_THRESHOLD):
|
|
79
|
+
"""The async ToolCallWrapper for ToolNode(awrap_tool_call=...) and the middleware's async path."""
|
|
80
|
+
|
|
81
|
+
async def wrapper(request, handler):
|
|
82
|
+
if is_halo_nav_tool(request.tool_call["name"]):
|
|
83
|
+
return await handler(request)
|
|
84
|
+
return _rewrite(session, threshold, await handler(request), request)
|
|
85
|
+
|
|
86
|
+
return wrapper
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class HaloMiddleware(AgentMiddleware):
|
|
90
|
+
"""The create_agent middleware. Provides both ``wrap_tool_call`` and ``awrap_tool_call`` over one
|
|
91
|
+
shared wrapper body, so the adapter works under both ``agent.invoke`` and ``agent.ainvoke``."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, session, threshold: int = _DEFAULT_THRESHOLD):
|
|
94
|
+
super().__init__()
|
|
95
|
+
self._sync = make_sync_wrapper(session, threshold)
|
|
96
|
+
self._async = make_async_wrapper(session, threshold)
|
|
97
|
+
|
|
98
|
+
def wrap_tool_call(self, request, handler):
|
|
99
|
+
return self._sync(request, handler)
|
|
100
|
+
|
|
101
|
+
async def awrap_tool_call(self, request, handler):
|
|
102
|
+
return await self._async(request, handler)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The navigation surface — how the model pulls back what the middleware withheld. A single plain
|
|
2
|
+
LangChain tool, ``halo_fetch``, backed by the session store and verified on read. No MCP server: it is
|
|
3
|
+
an ordinary tool, appended to the agent's tools list and bound to the model like any other.
|
|
4
|
+
|
|
5
|
+
One tool on purpose: an earlier design (in the Claude adapter's history) also exposed halo_walk and the
|
|
6
|
+
model wasted turns choosing between them and fetching a branch ref through the leaf tool. halo_fetch now
|
|
7
|
+
does both — a leaf ref returns its value, a branch ref returns its sub-shape — so there is a single
|
|
8
|
+
batch API and nothing to disambiguate.
|
|
9
|
+
|
|
10
|
+
halo_fetch takes a LIST of refs: each fetch is a separate model round trip, the dominant latency in the
|
|
11
|
+
loop, so the model is nudged to gather every ref a step needs and pull them in one call. It returns a
|
|
12
|
+
per-ref result, so one unknown or tampered entry (surfaced as a HashMismatch) never sinks the batch.
|
|
13
|
+
|
|
14
|
+
It is declared ``response_format="content_and_artifact"``: the model reads the per-ref values from the
|
|
15
|
+
message content, and the same structured result is attached as the artifact (kept in graph state for
|
|
16
|
+
audit/replay, not re-sent to the model). The pure helper (halo_fetch) is split from the tool wrapping so
|
|
17
|
+
it can be tested without the LangChain runtime."""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
from langchain_core.tools import tool
|
|
22
|
+
|
|
23
|
+
from .constants import HALO_FETCH_TOOL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def halo_fetch(session, refs) -> dict:
|
|
27
|
+
"""Fetch several refs at once, verified, with a per-ref result; branch refs return their sub-shape."""
|
|
28
|
+
return session.fetch(refs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FETCH_DESC = (
|
|
32
|
+
"The one tool for reading a halo map. Pass ALL the refs a step needs in one call (refs is a list) "
|
|
33
|
+
"rather than one at a time — each call is a separate round trip. A ref like `m1.income` (or a raw "
|
|
34
|
+
"`h:` handle) that points at a value returns it as {ok:true,value}; a ref that points at a "
|
|
35
|
+
"[branch] returns {ok:true,kind:'branch',fields:[…]} listing its sub-refs to fetch next. An entry "
|
|
36
|
+
"with ok=false (e.g. HashMismatch) means that data must not be trusted."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_halo_fetch_tool(session):
|
|
41
|
+
"""Build the single ``halo_fetch`` LangChain tool over the given session."""
|
|
42
|
+
|
|
43
|
+
@tool(HALO_FETCH_TOOL, description=_FETCH_DESC, response_format="content_and_artifact")
|
|
44
|
+
def _halo_fetch(refs: list[str]):
|
|
45
|
+
results = halo_fetch(session, refs)
|
|
46
|
+
# content: the JSON the model reads (the fetched values / branch sub-shapes).
|
|
47
|
+
# artifact: the same structured result, kept in graph state for audit/replay.
|
|
48
|
+
return json.dumps(results), results
|
|
49
|
+
|
|
50
|
+
return _halo_fetch
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Serialization at the SDK boundary: measure a raw tool result, parse it into a JSON value the
|
|
2
|
+
core can encode, and serialize a halo envelope back into the string the model sees.
|
|
3
|
+
|
|
4
|
+
updatedToolOutput carries the tool's visible output. What the model sees in place of the blob is a
|
|
5
|
+
SHAPE MAP, not the raw envelope: the root kind stated up front, then one line per field giving its
|
|
6
|
+
ref, its kind (leaf vs ``[branch]``), and a bounded preview. This is deliberately not the hashed
|
|
7
|
+
envelope JSON — the 64-char branch handles are dead weight to the model (it navigates by
|
|
8
|
+
``<mapId>.<field>`` refs, which the session's navigator resolves from its own registry), and showing
|
|
9
|
+
only names without kinds is exactly what made the model guess the root kind and fetch a branch as if
|
|
10
|
+
it were a leaf. The hints close both gaps. The envelope object itself is unchanged and still held
|
|
11
|
+
verbatim in the session for verified reads."""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
# A per-field descriptor (a plain dict ``{"ref", "kind", "shape", "preview"}``) the model sees in the
|
|
16
|
+
# shape map. ``shape`` is a short structural tag ("number", "string[42]", "object{4}",
|
|
17
|
+
# "[branch] object{7}"); ``preview`` is a bounded sample of a leaf's value, or a branch's child names.
|
|
18
|
+
# Built by HaloSession.describe (it needs the store to read each child node); rendered here.
|
|
19
|
+
|
|
20
|
+
_PREVIEW_MAX = 80
|
|
21
|
+
|
|
22
|
+
# A tool's raw output is a string, an already-parsed value, or the MCP content-block shape
|
|
23
|
+
# ``{"content": [{"type": "text", "text": ...}]}``.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def size_of(raw) -> int:
|
|
27
|
+
"""Byte length of a raw tool output, used for the size threshold. Never raises."""
|
|
28
|
+
if raw is None:
|
|
29
|
+
return 0
|
|
30
|
+
if isinstance(raw, str):
|
|
31
|
+
return len(raw.encode("utf-8"))
|
|
32
|
+
try:
|
|
33
|
+
return len(json.dumps(raw).encode("utf-8"))
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return 0 # unserializable (e.g. a cycle) — treat as below threshold, pass through
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_tool_output(raw):
|
|
39
|
+
"""Coerce a raw tool output into a JSON value to encode.
|
|
40
|
+
|
|
41
|
+
Strings are parsed as JSON when they look like it and otherwise kept as a string leaf; the MCP
|
|
42
|
+
``{"content": [{"text": ...}]}`` block is unwrapped to its concatenated text (parsed when it is
|
|
43
|
+
itself JSON). Anything already-structured is returned as is. Returns ``None`` when there is
|
|
44
|
+
nothing meaningful to encode.
|
|
45
|
+
"""
|
|
46
|
+
if raw is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(raw, str):
|
|
49
|
+
return _parse_maybe_json(raw)
|
|
50
|
+
# The MCP result may arrive as {"content": [{type:text,text}]} OR as the bare content-block list
|
|
51
|
+
# [{type:text,text}] (the shape some SDK hosts hand the hook). Unwrap either.
|
|
52
|
+
if isinstance(raw, (dict, list)):
|
|
53
|
+
text = _extract_content_text(raw)
|
|
54
|
+
if text is not None:
|
|
55
|
+
return _parse_maybe_json(text)
|
|
56
|
+
return raw
|
|
57
|
+
return raw # int | float | bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_maybe_json(s: str):
|
|
61
|
+
trimmed = s.strip()
|
|
62
|
+
if trimmed == "":
|
|
63
|
+
return s
|
|
64
|
+
# Only attempt a parse on something that looks like JSON, so plain prose stays a string leaf.
|
|
65
|
+
if trimmed[0] in "{[\"":
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(trimmed)
|
|
68
|
+
except (json.JSONDecodeError, ValueError):
|
|
69
|
+
return s
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_content_text(value):
|
|
74
|
+
"""Unwrap the MCP content-block shape to its text, concatenating text blocks.
|
|
75
|
+
|
|
76
|
+
Accepts both the ``{"content": [...]}`` dict and a bare ``[{type:text,text}]`` list. Returns None
|
|
77
|
+
when the value is not content blocks (a genuine list/dict result), so it is encoded as-is.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
content = value
|
|
81
|
+
elif isinstance(value, dict) and isinstance(value.get("content"), list):
|
|
82
|
+
content = value["content"]
|
|
83
|
+
else:
|
|
84
|
+
return None
|
|
85
|
+
texts = [
|
|
86
|
+
block["text"]
|
|
87
|
+
for block in content
|
|
88
|
+
if isinstance(block, dict)
|
|
89
|
+
and block.get("type") == "text"
|
|
90
|
+
and isinstance(block.get("text"), str)
|
|
91
|
+
]
|
|
92
|
+
return "".join(texts) if texts else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def shape_of(value) -> str:
|
|
96
|
+
"""A short structural tag for a leaf value: its kind plus a size for strings/lists/objects."""
|
|
97
|
+
if value is None:
|
|
98
|
+
return "null"
|
|
99
|
+
if isinstance(value, bool):
|
|
100
|
+
return "boolean"
|
|
101
|
+
if isinstance(value, (int, float)):
|
|
102
|
+
return "number"
|
|
103
|
+
if isinstance(value, str):
|
|
104
|
+
return f"string[{len(value)}]"
|
|
105
|
+
if isinstance(value, list):
|
|
106
|
+
return f"array[{len(value)}]"
|
|
107
|
+
return f"object{{{len(value)}}}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def preview_of(value, max_len: int = _PREVIEW_MAX) -> str:
|
|
111
|
+
"""A bounded, single-line JSON sample of a value — enough to recognise it, never the whole payload."""
|
|
112
|
+
try:
|
|
113
|
+
s = json.dumps(value, separators=(",", ":"))
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return ""
|
|
116
|
+
return s if len(s) <= max_len else f"{s[: max_len - 1]}…"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_scalar_shape(shape: str) -> bool:
|
|
120
|
+
return shape in ("null", "boolean", "number")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _hint_line(hint: dict) -> str:
|
|
124
|
+
"""One field line: ref, structural tag, then the preview/child-names."""
|
|
125
|
+
preview = hint.get("preview")
|
|
126
|
+
if preview:
|
|
127
|
+
eq = "= " if hint["kind"] == "leaf" and _is_scalar_shape(hint["shape"]) else ""
|
|
128
|
+
tail = f" {eq}{preview}"
|
|
129
|
+
else:
|
|
130
|
+
tail = ""
|
|
131
|
+
return f' {hint["ref"]} {hint["shape"]}{tail}'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _root_shape(names) -> str:
|
|
135
|
+
"""``object, 7 fields`` / ``array, 4 chunks`` for a branch root."""
|
|
136
|
+
n = len(names)
|
|
137
|
+
array_like = n > 0 and all(name.isdigit() for name in names)
|
|
138
|
+
if array_like:
|
|
139
|
+
return f"array, {n} chunks"
|
|
140
|
+
return f"object, {n} field{'' if n == 1 else 's'}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _short_tool(tool: str) -> str:
|
|
144
|
+
if tool.startswith("mcp__"):
|
|
145
|
+
parts = tool.split("__", 2)
|
|
146
|
+
if len(parts) == 3:
|
|
147
|
+
return parts[2]
|
|
148
|
+
return tool
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def serialize_envelope(envelope: dict, hints=None) -> str:
|
|
152
|
+
"""Serialize an envelope into the string the model receives in place of the blob: the map id and
|
|
153
|
+
root kind stated up front, then (when ``hints`` are supplied) one line per field with its kind and
|
|
154
|
+
a bounded preview. With no hints it degrades to a bare ref list — still no hashes, still names the
|
|
155
|
+
root kind, just without the per-field previews the session computes from the store."""
|
|
156
|
+
source = envelope.get("source") or {}
|
|
157
|
+
map_id = source.get("id", "?")
|
|
158
|
+
names = list(envelope["view"]["branches"])
|
|
159
|
+
is_leaf_root = len(names) == 0
|
|
160
|
+
if is_leaf_root:
|
|
161
|
+
root = envelope["view"]["summary"]
|
|
162
|
+
if root.startswith("leaf:"):
|
|
163
|
+
root = root[len("leaf:"):].strip()
|
|
164
|
+
else:
|
|
165
|
+
root = _root_shape(names)
|
|
166
|
+
from_ = f" from {_short_tool(source['tool'])}" if source.get("tool") else ""
|
|
167
|
+
|
|
168
|
+
head = (
|
|
169
|
+
f'[halo] map "{map_id}" — {root}{from_}, stored out of context. '
|
|
170
|
+
"Pull only the fields you need with ONE halo_fetch call — batch every ref into that one call; "
|
|
171
|
+
"each call is a round trip. A [branch] ref expands to its sub-refs when fetched; every other "
|
|
172
|
+
"ref returns its value."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if is_leaf_root:
|
|
176
|
+
return f'{head}\nFetch "{map_id}" itself to read the value.'
|
|
177
|
+
|
|
178
|
+
if hints:
|
|
179
|
+
lines = [_hint_line(h) for h in hints]
|
|
180
|
+
else:
|
|
181
|
+
lines = [f" {map_id}.{n}" for n in names]
|
|
182
|
+
return f"{head}\nFields:\n" + "\n".join(lines)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""HaloSession holds the one piece of shared state in the adapter — the store and the per-entity map
|
|
2
|
+
registry — and exposes the three operations the hook and the navigation tools sit on:
|
|
3
|
+
|
|
4
|
+
ingest(tool_name, tool_input, value) -> {"envelope", "id"} (producer; folds into the map)
|
|
5
|
+
walk(ref) -> {"summary", "branches"}
|
|
6
|
+
fetch(refs) -> verified leaves, per-ref
|
|
7
|
+
|
|
8
|
+
One entity, one map (SDK architecture, Section 9.1): key_of decides which calls share a map id. A
|
|
9
|
+
first call for an id ``encode``s ``{tool: value}``; later calls for the same id ``extend`` that map's
|
|
10
|
+
root with a branch named after the tool, so a customer assembled across several endpoints becomes one
|
|
11
|
+
growing map rather than several fragments. Because extend reuses unchanged child handles and retains
|
|
12
|
+
the prior root, each entity map also carries its own version history for free.
|
|
13
|
+
|
|
14
|
+
The session is SYNC — it sits on the sync Python core (matching the port convention). Only the thin
|
|
15
|
+
SDK-facing wrappers (the async hook and tool handlers) are async, as the SDK requires.
|
|
16
|
+
|
|
17
|
+
Every read is verified by the core Navigator, so the store stays untrusted. The Navigator resolves
|
|
18
|
+
map-scoped refs (``m1.income``, ``123.get_appointments``) against the registered envelopes; raw
|
|
19
|
+
handles always work without registration."""
|
|
20
|
+
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from halo_format import (
|
|
25
|
+
HashMismatch,
|
|
26
|
+
MemoryStore,
|
|
27
|
+
Navigator,
|
|
28
|
+
WrongKind,
|
|
29
|
+
branch_node,
|
|
30
|
+
build_envelope,
|
|
31
|
+
decode,
|
|
32
|
+
derive_summary,
|
|
33
|
+
hash_bytes,
|
|
34
|
+
is_branch,
|
|
35
|
+
is_leaf,
|
|
36
|
+
serialize,
|
|
37
|
+
)
|
|
38
|
+
from halo_format import encode as _encode
|
|
39
|
+
|
|
40
|
+
from .accumulate import KeyOf, arg_join
|
|
41
|
+
from .serialize import preview_of, shape_of
|
|
42
|
+
|
|
43
|
+
_MAX_CHILD_NAMES = 10
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now_iso() -> str:
|
|
47
|
+
return datetime.now(timezone.utc).isoformat()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _branch_name(tool_name: str) -> str:
|
|
51
|
+
"""A short branch label for an accumulated tool: strip the ``mcp__<server>__`` prefix."""
|
|
52
|
+
if tool_name.startswith("mcp__"):
|
|
53
|
+
parts = tool_name.split("__", 2)
|
|
54
|
+
if len(parts) == 3:
|
|
55
|
+
return parts[2]
|
|
56
|
+
return tool_name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HaloSession:
|
|
60
|
+
def __init__(self, store=None, key_of: Optional[KeyOf] = None, alg: str = "sha256", now=None):
|
|
61
|
+
self.store = store if store is not None else MemoryStore()
|
|
62
|
+
self._key_of = key_of or arg_join
|
|
63
|
+
self._alg = alg
|
|
64
|
+
self._now = now or _now_iso
|
|
65
|
+
self._maps: dict[str, dict] = {} # id -> latest envelope, for ref resolution + accumulation
|
|
66
|
+
# id -> {branch_name: subtree_root_handle}: the per-tool trees folded into each entity map.
|
|
67
|
+
self._entity_tools: dict[str, dict[str, str]] = {}
|
|
68
|
+
self._navigator: Optional[Navigator] = None # seeded by the first map, extended via register
|
|
69
|
+
self._synthetic = 0
|
|
70
|
+
|
|
71
|
+
def ingest(self, tool_name: str, tool_input, value) -> dict:
|
|
72
|
+
"""Encode a tool result into the store and return its envelope and map id.
|
|
73
|
+
|
|
74
|
+
A map's FIRST result is encoded flat — the value's own fields become the top-level branches,
|
|
75
|
+
so the model sees them in the envelope and can batch-fetch leaves with no extra walk. Only
|
|
76
|
+
when a SECOND tool result accumulates into the same entity map (per key_of) do we namespace
|
|
77
|
+
each tool's tree under its (short) name, since then the field names would otherwise collide.
|
|
78
|
+
"""
|
|
79
|
+
map_id = self._key_of(tool_name, tool_input)
|
|
80
|
+
if map_id is None:
|
|
81
|
+
self._synthetic += 1
|
|
82
|
+
map_id = f"m{self._synthetic}"
|
|
83
|
+
|
|
84
|
+
# Encode the value's own tree once; reuse its root as this tool's subtree under the entity.
|
|
85
|
+
sub = _encode(value, self.store, alg=self._alg)
|
|
86
|
+
tools = self._entity_tools.setdefault(map_id, {})
|
|
87
|
+
tools[_branch_name(tool_name)] = sub["handle"]
|
|
88
|
+
|
|
89
|
+
if len(tools) == 1:
|
|
90
|
+
# Single result: the map IS the value's tree — fields are top-level, no walk needed.
|
|
91
|
+
envelope = sub["envelope"]
|
|
92
|
+
else:
|
|
93
|
+
# Accumulation: namespace each tool's tree under its name (reusing every subtree handle;
|
|
94
|
+
# only the new root node is stored).
|
|
95
|
+
branches = dict(tools)
|
|
96
|
+
root = self.store.put(serialize(branch_node(derive_summary(None, branches), branches)))
|
|
97
|
+
envelope = build_envelope(root, decode(self.store.get(root)), self._alg)
|
|
98
|
+
|
|
99
|
+
# source identifies the map and traces it to the call; it is envelope-only and never hashed.
|
|
100
|
+
envelope["source"] = {
|
|
101
|
+
"id": map_id,
|
|
102
|
+
"tool": tool_name,
|
|
103
|
+
"args": tool_input,
|
|
104
|
+
"ts": self._now(),
|
|
105
|
+
}
|
|
106
|
+
self._maps[map_id] = envelope
|
|
107
|
+
self._register(envelope)
|
|
108
|
+
return {"envelope": envelope, "id": map_id}
|
|
109
|
+
|
|
110
|
+
def _register(self, envelope: dict) -> None:
|
|
111
|
+
if self._navigator is not None:
|
|
112
|
+
self._navigator.register(envelope)
|
|
113
|
+
else:
|
|
114
|
+
self._navigator = Navigator(envelope, self.store)
|
|
115
|
+
|
|
116
|
+
def walk(self, ref: str) -> dict:
|
|
117
|
+
"""Verified walk of a branch ref (raw handle or ``mapId.branch[.child]``). Internal — not a tool."""
|
|
118
|
+
if self._navigator is None:
|
|
119
|
+
raise RuntimeError("no halo maps in this session yet")
|
|
120
|
+
return self._navigator.walk(ref)
|
|
121
|
+
|
|
122
|
+
def fetch(self, refs) -> dict:
|
|
123
|
+
"""Verified batch fetch — the single navigation surface. Leaves return their value; a ref that
|
|
124
|
+
lands on a branch returns the branch's sub-shape instead of a WrongKind error, so one tool
|
|
125
|
+
covers both pulling and expanding. One bad ref never sinks the others (per-ref ok/error)."""
|
|
126
|
+
if self._navigator is None:
|
|
127
|
+
return {ref: {"ok": False, "error": "UnknownHandle"} for ref in refs}
|
|
128
|
+
# The core batch verifies every leaf in one pass; branch refs come back WrongKind, which we
|
|
129
|
+
# turn into a sub-shape expansion below rather than surfacing as an error.
|
|
130
|
+
base = self._navigator.fetch_many(refs)
|
|
131
|
+
out: dict = {}
|
|
132
|
+
for ref in refs:
|
|
133
|
+
res = base[ref]
|
|
134
|
+
if res["ok"]:
|
|
135
|
+
out[ref] = {"ok": True, "value": res["value"]}
|
|
136
|
+
elif res.get("error") == "WrongKind":
|
|
137
|
+
try:
|
|
138
|
+
out[ref] = self._expand(ref)
|
|
139
|
+
except Exception:
|
|
140
|
+
out[ref] = res
|
|
141
|
+
else:
|
|
142
|
+
out[ref] = res
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
def describe(self, envelope: dict) -> list:
|
|
146
|
+
"""Describe an envelope's top-level fields for the shape map the model sees: one hint dict per
|
|
147
|
+
branch, classified (leaf vs branch) and previewed by reading each child node. Sorted by ref so
|
|
148
|
+
the listing order is deterministic (matching the structural summary's sorted names)."""
|
|
149
|
+
map_id = (envelope.get("source") or {}).get("id", "")
|
|
150
|
+
hints = []
|
|
151
|
+
for name, handle in envelope["view"]["branches"].items():
|
|
152
|
+
ref = f"{map_id}.{name}" if map_id else name
|
|
153
|
+
try:
|
|
154
|
+
hints.append(self._hint_for(ref, handle))
|
|
155
|
+
except Exception:
|
|
156
|
+
hints.append({"ref": ref, "kind": "leaf", "shape": "?"})
|
|
157
|
+
hints.sort(key=lambda h: h["ref"])
|
|
158
|
+
return hints
|
|
159
|
+
|
|
160
|
+
def _expand(self, ref: str) -> dict:
|
|
161
|
+
"""Expand a branch ref into a fetch entry carrying its child refs + hints (the in-fetch
|
|
162
|
+
fallback so a mis-fetched branch self-corrects in the same call instead of dead-ending)."""
|
|
163
|
+
handle = self._navigator.resolve(ref)
|
|
164
|
+
node = self._read(handle)
|
|
165
|
+
if not is_branch(node):
|
|
166
|
+
raise WrongKind(f"expand expects a branch: {ref}")
|
|
167
|
+
fields = [self._hint_for(f"{ref}.{name}", h) for name, h in node["branches"].items()]
|
|
168
|
+
fields.sort(key=lambda h: h["ref"])
|
|
169
|
+
return {
|
|
170
|
+
"ok": True,
|
|
171
|
+
"kind": "branch",
|
|
172
|
+
"note": "This ref is a branch, not a value. halo_fetch the leaf refs below to read it.",
|
|
173
|
+
"fields": fields,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def _hint_for(self, ref: str, handle: str) -> dict:
|
|
177
|
+
"""Classify one child handle into a hint dict: a leaf gets its shape + a bounded value preview;
|
|
178
|
+
a branch gets a ``[branch]`` shape and its child names as the preview (one level, bounded)."""
|
|
179
|
+
node = self._read(handle)
|
|
180
|
+
if is_leaf(node):
|
|
181
|
+
value = node["value"]
|
|
182
|
+
return {"ref": ref, "kind": "leaf", "shape": shape_of(value), "preview": preview_of(value)}
|
|
183
|
+
child_names = list(node["branches"])
|
|
184
|
+
array_like = len(child_names) > 0 and all(n.isdigit() for n in child_names)
|
|
185
|
+
shape = f"[branch] {'array' if array_like else 'object'}{{{len(child_names)}}}"
|
|
186
|
+
shown = ", ".join(child_names[:_MAX_CHILD_NAMES])
|
|
187
|
+
more = ", …" if len(child_names) > _MAX_CHILD_NAMES else ""
|
|
188
|
+
return {"ref": ref, "kind": "branch", "shape": shape, "preview": f"↳ {shown}{more}"}
|
|
189
|
+
|
|
190
|
+
def _read(self, handle: str) -> dict:
|
|
191
|
+
"""A verified read of a handle from the store: re-hash the bytes under the bound alg before
|
|
192
|
+
decode, so the shape map can never describe substituted bytes. Same check the Navigator makes."""
|
|
193
|
+
data = self.store.get(handle)
|
|
194
|
+
if hash_bytes(data, self._alg) != handle:
|
|
195
|
+
raise HashMismatch(handle)
|
|
196
|
+
return decode(data)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""End-to-end through a real compiled LangGraph: the ToolNode runs the tool, the Halo wrapper rewrites
|
|
2
|
+
the ToolMessage to a shape map, the envelope lands in the artifact, and the session navigates back the
|
|
3
|
+
withheld leaves — verified. Also covers entity accumulation across two calls sharing an argument."""
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import AIMessage
|
|
6
|
+
from langchain_core.tools import tool
|
|
7
|
+
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
8
|
+
|
|
9
|
+
from halo_format_langgraph import halo_tool_node
|
|
10
|
+
|
|
11
|
+
BIG = {"profile": {"income": {"monthly": 4200}, "debts": {"monthly": 2604}}, "filler": "x" * 3000}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@tool
|
|
15
|
+
def bureau_report(applicant: int) -> dict:
|
|
16
|
+
"""Get a bureau report."""
|
|
17
|
+
return BIG
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool
|
|
21
|
+
def get_appointments(applicant: int) -> dict:
|
|
22
|
+
"""Get appointments for an applicant."""
|
|
23
|
+
return {"appointments": [{"id": i, "slot": f"2026-06-{i:02d}"} for i in range(1, 30)]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _graph(tools, **kw):
|
|
27
|
+
result = halo_tool_node(tools, now=lambda: "T", **kw)
|
|
28
|
+
g = StateGraph(MessagesState)
|
|
29
|
+
g.add_node("tools", result.tool_node)
|
|
30
|
+
g.add_edge(START, "tools")
|
|
31
|
+
g.add_edge("tools", END)
|
|
32
|
+
return g.compile(), result.session
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _call(name, args, cid):
|
|
36
|
+
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": cid, "type": "tool_call"}])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_tool_result_is_withheld_and_navigable():
|
|
40
|
+
app, session = _graph([bureau_report])
|
|
41
|
+
out = app.invoke({"messages": [_call("bureau_report", {"applicant": 99}, "c1")]})
|
|
42
|
+
tm = out["messages"][-1]
|
|
43
|
+
|
|
44
|
+
assert tm.content.startswith('[halo] map "99"')
|
|
45
|
+
assert tm.artifact["halo"] == "1"
|
|
46
|
+
# the model never sees the payload; the session fetches it back, verified
|
|
47
|
+
got = session.fetch(["99.profile"])["99.profile"]
|
|
48
|
+
assert got == {"ok": True, "value": {"debts": {"monthly": 2604}, "income": {"monthly": 4200}}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_nav_tool_runs_in_graph_and_is_not_re_encoded():
|
|
52
|
+
app, session = _graph([bureau_report])
|
|
53
|
+
app.invoke({"messages": [_call("bureau_report", {"applicant": 99}, "c1")]})
|
|
54
|
+
out = app.invoke({"messages": [_call("halo_fetch", {"refs": ["99.profile"]}, "c2")]})
|
|
55
|
+
ftm = out["messages"][-1]
|
|
56
|
+
assert not ftm.content.startswith("[halo] map") # passed through, not turned into a map
|
|
57
|
+
assert "income" in ftm.content
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_entity_accumulation_folds_two_calls_into_one_map():
|
|
61
|
+
# threshold low enough that the (modest) appointments result also crosses it and is ingested.
|
|
62
|
+
app, session = _graph([bureau_report, get_appointments], threshold=256)
|
|
63
|
+
app.invoke({"messages": [_call("bureau_report", {"applicant": 123}, "c1")]})
|
|
64
|
+
out = app.invoke({"messages": [_call("get_appointments", {"applicant": 123}, "c2")]})
|
|
65
|
+
tm = out["messages"][-1]
|
|
66
|
+
|
|
67
|
+
# second call for the same id folds in: the map is now namespaced by tool name
|
|
68
|
+
assert tm.artifact["source"]["id"] == "123"
|
|
69
|
+
branches = set(tm.artifact["view"]["branches"])
|
|
70
|
+
assert {"bureau_report", "get_appointments"} <= branches
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""install_halo (the create_agent path) and halo_tool_node (the StateGraph path): both append the
|
|
2
|
+
navigation tool and wire the wrapper non-destructively, and both share one session."""
|
|
3
|
+
|
|
4
|
+
from langchain_core.tools import tool
|
|
5
|
+
|
|
6
|
+
from halo_format_langgraph import (
|
|
7
|
+
HaloMiddleware,
|
|
8
|
+
HaloSession,
|
|
9
|
+
halo_tool_node,
|
|
10
|
+
install_halo,
|
|
11
|
+
)
|
|
12
|
+
from halo_format_langgraph.constants import HALO_FETCH_TOOL
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@tool
|
|
16
|
+
def existing(x: int) -> dict:
|
|
17
|
+
"""An existing tool."""
|
|
18
|
+
return {"x": x}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_install_halo_appends_tool_and_middleware_preserving_existing():
|
|
22
|
+
class OtherMW: # a stand-in for the host's own middleware
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
other = OtherMW()
|
|
26
|
+
result = install_halo(tools=[existing], middleware=[other])
|
|
27
|
+
|
|
28
|
+
assert [t.name for t in result.tools] == ["existing", HALO_FETCH_TOOL]
|
|
29
|
+
assert result.middleware[0] is other
|
|
30
|
+
assert isinstance(result.middleware[-1], HaloMiddleware)
|
|
31
|
+
assert isinstance(result.session, HaloSession)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_install_halo_with_no_inputs():
|
|
35
|
+
result = install_halo()
|
|
36
|
+
assert [t.name for t in result.tools] == [HALO_FETCH_TOOL]
|
|
37
|
+
assert len(result.middleware) == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_install_halo_shares_passed_session():
|
|
41
|
+
session = HaloSession()
|
|
42
|
+
result = install_halo(tools=[existing], session=session)
|
|
43
|
+
assert result.session is session
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_halo_tool_node_builds_node_with_appended_tool():
|
|
47
|
+
result = halo_tool_node([existing])
|
|
48
|
+
assert [t.name for t in result.tools] == ["existing", HALO_FETCH_TOOL]
|
|
49
|
+
assert isinstance(result.session, HaloSession)
|
|
50
|
+
assert result.tool_node is not None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The encode middleware: the wrapper rewrites a large ToolMessage into a shape map (content) plus the
|
|
2
|
+
envelope (artifact), and passes through everything it must — the nav tool, small results, and non-
|
|
3
|
+
ToolMessage returns (a Command). Sync and async share one body, so both are exercised."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
from langchain_core.messages import ToolMessage
|
|
9
|
+
from langgraph.types import Command
|
|
10
|
+
|
|
11
|
+
from halo_format_langgraph import HaloMiddleware, HaloSession
|
|
12
|
+
from halo_format_langgraph.middleware import _PRIOR_ARTIFACT_KEY
|
|
13
|
+
|
|
14
|
+
BIG = '{"profile":{"income":{"monthly":4200},"debts":{"monthly":2604}},"filler":"' + "x" * 3000 + '"}'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _req(name, args):
|
|
18
|
+
# The wrapper only reads request.tool_call; a namespace is enough for the unit level.
|
|
19
|
+
return SimpleNamespace(tool_call={"name": name, "args": args, "id": "c1"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _handler(content, artifact=None):
|
|
23
|
+
def handler(request):
|
|
24
|
+
return ToolMessage(
|
|
25
|
+
content=content, tool_call_id="c1", name=request.tool_call["name"], artifact=artifact
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return handler
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _mw():
|
|
32
|
+
session = HaloSession(now=lambda: "T")
|
|
33
|
+
return HaloMiddleware(session, threshold=2048), session
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_large_result_becomes_shape_map_with_envelope_artifact():
|
|
37
|
+
mw, session = _mw()
|
|
38
|
+
out = mw.wrap_tool_call(_req("bureau", {"applicant": 99}), _handler(BIG))
|
|
39
|
+
|
|
40
|
+
assert out.content.startswith('[halo] map "99"') # arg_join keyed on applicant=99
|
|
41
|
+
assert out.artifact["halo"] == "1"
|
|
42
|
+
assert out.artifact["source"]["id"] == "99"
|
|
43
|
+
# the full payload is reachable from the store, not in content
|
|
44
|
+
assert session.fetch(["99.profile"])["99.profile"]["ok"] is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_async_path_matches_sync():
|
|
48
|
+
session = HaloSession(now=lambda: "T")
|
|
49
|
+
mw = HaloMiddleware(session, 2048)
|
|
50
|
+
|
|
51
|
+
async def handler(request):
|
|
52
|
+
return ToolMessage(content=BIG, tool_call_id="c1", name=request.tool_call["name"])
|
|
53
|
+
|
|
54
|
+
out = asyncio.run(mw.awrap_tool_call(_req("bureau", {"applicant": 99}), handler))
|
|
55
|
+
assert out.content.startswith('[halo] map "99"')
|
|
56
|
+
assert out.artifact["halo"] == "1"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_nav_tool_output_passes_through_untouched():
|
|
60
|
+
# MANDATORY: a fetched leaf must never be re-encoded into a new map.
|
|
61
|
+
mw, _ = _mw()
|
|
62
|
+
out = mw.wrap_tool_call(_req("halo_fetch", {"refs": ["x"]}), _handler(BIG))
|
|
63
|
+
assert out.content == BIG
|
|
64
|
+
assert out.artifact is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_small_result_passes_through():
|
|
68
|
+
mw, _ = _mw()
|
|
69
|
+
out = mw.wrap_tool_call(_req("ping", {"x": 1}), _handler('{"ok":true}'))
|
|
70
|
+
assert out.content == '{"ok":true}'
|
|
71
|
+
assert out.artifact is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_command_return_passes_through():
|
|
75
|
+
mw, _ = _mw()
|
|
76
|
+
|
|
77
|
+
def handler(request):
|
|
78
|
+
return Command(update={"foo": "bar"})
|
|
79
|
+
|
|
80
|
+
out = mw.wrap_tool_call(_req("set_state", {"a": 1}), handler)
|
|
81
|
+
assert isinstance(out, Command)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_upstream_artifact_is_preserved():
|
|
85
|
+
mw, _ = _mw()
|
|
86
|
+
out = mw.wrap_tool_call(_req("with_art", {"a": 7}), _handler(BIG, artifact={"mine": 1}))
|
|
87
|
+
assert out.artifact["halo"] == "1" # Halo's envelope takes the artifact slot
|
|
88
|
+
assert out.additional_kwargs[_PRIOR_ARTIFACT_KEY] == {"mine": 1} # upstream artifact kept
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_original_message_not_mutated():
|
|
92
|
+
# _rewrite returns a copy; the handler's message object is left intact.
|
|
93
|
+
mw, _ = _mw()
|
|
94
|
+
original = ToolMessage(content=BIG, tool_call_id="c1", name="bureau")
|
|
95
|
+
|
|
96
|
+
def handler(request):
|
|
97
|
+
return original
|
|
98
|
+
|
|
99
|
+
out = mw.wrap_tool_call(_req("bureau", {"applicant": 99}), handler)
|
|
100
|
+
assert out is not original
|
|
101
|
+
assert original.content == BIG # untouched
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The navigation tool: a single LangChain tool, halo_fetch, declared content_and_artifact. A leaf ref
|
|
2
|
+
returns its value; a branch ref returns its sub-shape rather than erroring; the per-ref result rides in
|
|
3
|
+
both content (read by the model) and artifact (kept in state)."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from halo_format_langgraph import HaloSession, create_halo_fetch_tool
|
|
8
|
+
from halo_format_langgraph.constants import HALO_FETCH_TOOL
|
|
9
|
+
from halo_format_langgraph.nav_tool import halo_fetch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _seed():
|
|
13
|
+
# Large enough (> the core's 1024-byte inline threshold) that the top-level object carves into
|
|
14
|
+
# branches: a scalar leaf (score) and a chunked-array branch (tradelines).
|
|
15
|
+
session = HaloSession(now=lambda: "T")
|
|
16
|
+
session.ingest(
|
|
17
|
+
"bureau",
|
|
18
|
+
{"applicant": 99},
|
|
19
|
+
{
|
|
20
|
+
"score": 612,
|
|
21
|
+
"tradelines": [
|
|
22
|
+
{"id": i, "creditor": f"Bank {i}", "balance": i * 137, "status": "open"}
|
|
23
|
+
for i in range(40)
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
return session
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_helper_fetches_leaf_verified():
|
|
31
|
+
session = _seed()
|
|
32
|
+
got = halo_fetch(session, ["99.score"])
|
|
33
|
+
assert got["99.score"] == {"ok": True, "value": 612}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_helper_branch_ref_returns_sub_shape():
|
|
37
|
+
session = _seed()
|
|
38
|
+
got = halo_fetch(session, ["99.tradelines"])
|
|
39
|
+
assert got["99.tradelines"]["ok"] is True
|
|
40
|
+
assert got["99.tradelines"]["kind"] == "branch"
|
|
41
|
+
assert any(f["ref"] == "99.tradelines.0" for f in got["99.tradelines"]["fields"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_tool_is_named_and_content_and_artifact():
|
|
45
|
+
session = _seed()
|
|
46
|
+
tool = create_halo_fetch_tool(session)
|
|
47
|
+
assert tool.name == HALO_FETCH_TOOL
|
|
48
|
+
assert tool.response_format == "content_and_artifact"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_tool_invocation_returns_content_and_artifact():
|
|
52
|
+
session = _seed()
|
|
53
|
+
tool = create_halo_fetch_tool(session)
|
|
54
|
+
msg = tool.invoke(
|
|
55
|
+
{"type": "tool_call", "name": HALO_FETCH_TOOL, "args": {"refs": ["99.score"]}, "id": "c1"}
|
|
56
|
+
)
|
|
57
|
+
# content is the JSON the model reads; artifact is the same structured result kept in state.
|
|
58
|
+
assert json.loads(msg.content)["99.score"] == {"ok": True, "value": 612}
|
|
59
|
+
assert msg.artifact["99.score"] == {"ok": True, "value": 612}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_bad_ref_is_per_entry_error_not_a_raise():
|
|
63
|
+
session = _seed()
|
|
64
|
+
got = halo_fetch(session, ["99.score", "99.missing"])
|
|
65
|
+
assert got["99.score"]["ok"] is True
|
|
66
|
+
assert got["99.missing"]["ok"] is False
|