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.
@@ -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