vinc-langgraph 0.1.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.
- vinc_langgraph-0.1.0/.gitignore +52 -0
- vinc_langgraph-0.1.0/PKG-INFO +83 -0
- vinc_langgraph-0.1.0/README.md +59 -0
- vinc_langgraph-0.1.0/constraints-min.txt +4 -0
- vinc_langgraph-0.1.0/examples/approve_then_record.py +70 -0
- vinc_langgraph-0.1.0/pyproject.toml +34 -0
- vinc_langgraph-0.1.0/src/vinc_langgraph/__init__.py +27 -0
- vinc_langgraph-0.1.0/src/vinc_langgraph/helpers.py +152 -0
- vinc_langgraph-0.1.0/src/vinc_langgraph/middleware.py +96 -0
- vinc_langgraph-0.1.0/src/vinc_langgraph/py.typed +0 -0
- vinc_langgraph-0.1.0/tests/test_langgraph.py +207 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
dist/
|
|
6
|
+
# 예외: 디자인 허브 산출물은 **계약**이라 추적한다. 소비 리포의 사본
|
|
7
|
+
# (app/src/tokens.*.css·theme.ts)이 커밋돼 있으므로 그 비교 대상도 리포에 있어야
|
|
8
|
+
# `sync_out.py --check`(CI 드리프트 게이트)가 성립한다. 없으면 게이트가
|
|
9
|
+
# "[MISSING·허브]" 로 죽는다 — 2026-08-08 CI 실패의 원인.
|
|
10
|
+
!design-hub/dist/
|
|
11
|
+
# 단, Claude Design 푸시 번들은 제외 — 업로드 직전에 만드는 임시 산출물이고
|
|
12
|
+
# 대조할 커밋된 소비 사본이 없다(그쪽은 claude.ai 프로젝트에 산다).
|
|
13
|
+
design-hub/dist/claude-design/
|
|
14
|
+
build/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
models/
|
|
17
|
+
vinc_store
|
|
18
|
+
*.lbug
|
|
19
|
+
vinc_e2e/
|
|
20
|
+
node_modules/
|
|
21
|
+
app/src-tauri/target/
|
|
22
|
+
app/src-tauri/binaries/
|
|
23
|
+
app/src-tauri/gen/
|
|
24
|
+
# .claude/ 는 기기별 설정(launch.json·ui-events.jsonl·worktrees)이라 **어느 깊이에서도**
|
|
25
|
+
# 추적하지 않는다. 예외는 이제 없다. 역할 껍데기는 `agents/shells/` 에서 추적되고
|
|
26
|
+
# (`agents_sync.py --check` 가 CI 에서 대조), 하네스가 읽는 사본은 리포 밖 워크스페이스
|
|
27
|
+
# 루트에 `--install` 이 쓴다. 리포 안 `.claude/agents` 는 체크아웃의 브랜치를 따라
|
|
28
|
+
# 수가 달라지는 두 번째 로스터였다.
|
|
29
|
+
# ⚠ 전에는 루트의 `.claude/agents/` 만 되살리려고 네 줄을 겹쳐 썼다(한 줄로는 예외가
|
|
30
|
+
# 사문이 되거나 `_migration/**/.claude/` 중첩본이 새어 나갔다, 2026-08-23 PR #69).
|
|
31
|
+
# 예외가 사라졌으니 모든 깊이를 잡는 이 한 줄이면 된다.
|
|
32
|
+
.claude/
|
|
33
|
+
# 서명·접속 키 — 커밋되면 되돌릴 수 없다. 지금 키들은 리포 **바깥**(워크스페이스
|
|
34
|
+
# 루트)에 있어 커밋된 적이 없지만, 누가 안으로 옮겨도 막히도록 패턴을 박아 둔다.
|
|
35
|
+
# 업데이터 개인키를 잃으면 배포된 전 사용자가 자동 업데이트에서 영구 이탈한다
|
|
36
|
+
# (복구 경로 없음 — DEPLOYMENT.md §1.6 백업 절차 참조).
|
|
37
|
+
*.key
|
|
38
|
+
*.pem
|
|
39
|
+
*.p12
|
|
40
|
+
*.pfx
|
|
41
|
+
|
|
42
|
+
# uv 캐시 락파일 — 로컬 테스트 실행(`uv run`)이 만든다. 정본 의존성은
|
|
43
|
+
# pyproject.toml 이고, 이 락은 기기별 파생물이라 추적하지 않는다.
|
|
44
|
+
uv.lock
|
|
45
|
+
|
|
46
|
+
# 평가·리허설 부산물 — 모델 가중치 수백 MB 가 여기 앉는다.
|
|
47
|
+
# 이 워킹트리는 세션 공유라 남의 `git add -A` 가 이것을 집는다.
|
|
48
|
+
.cache/
|
|
49
|
+
work/
|
|
50
|
+
|
|
51
|
+
# design-hub capture PNGs are build products of scripts/capture_*.mjs; the manifests are committed
|
|
52
|
+
design-hub/reports/captures/**/*.png
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vinc-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Vinc for LangGraph and LangChain agents: bring decisions and records from your knowledge graph into graph nodes and model calls, and record an episode only when you choose to.
|
|
5
|
+
Project-URL: Homepage, https://vincs.io
|
|
6
|
+
Project-URL: Documentation, https://vincs.io/docs/
|
|
7
|
+
Author: Vinculums
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: knowledge-graph,langchain,langgraph,memory,middleware,vinc
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: langchain-core>=1.0
|
|
16
|
+
Requires-Dist: langgraph>=1.0
|
|
17
|
+
Requires-Dist: vinc-client<0.2,>=0.1
|
|
18
|
+
Provides-Extra: agents
|
|
19
|
+
Requires-Dist: langchain>=1.0; extra == 'agents'
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: langchain>=1.0; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# vinc-langgraph
|
|
26
|
+
|
|
27
|
+
[Vinc](https://vincs.io) for [LangGraph](https://langchain-ai.github.io/langgraph/) and LangChain agents. Vinc is a knowledge graph you share with AI: the decisions, records and documents your team wrote, with the reasons attached. This package brings the relevant part of it into your graph nodes and model calls.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install vinc-langgraph # node helpers and tools
|
|
33
|
+
pip install "vinc-langgraph[agents]" # plus the middleware for create_agent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Set `VINC_API_KEY` to a member key. A `vinc_ro_` key is enough for everything except `record_episode`.
|
|
37
|
+
|
|
38
|
+
## In a graph node
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from vinc_client import VincClient
|
|
42
|
+
from vinc_langgraph import build_system_message, get_vinc_context
|
|
43
|
+
|
|
44
|
+
vinc = VincClient()
|
|
45
|
+
|
|
46
|
+
def review(state):
|
|
47
|
+
block = get_vinc_context(vinc, state["messages"]) # newest human message
|
|
48
|
+
system = build_system_message(block, "You review design changes.")
|
|
49
|
+
return {"messages": [llm.invoke([system, *state["messages"]])]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`get_vinc_context` makes one call, two when nothing matched or several nodes tied (it then widens with a search). It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the node goes on without it. A wrong key or space raises.
|
|
53
|
+
|
|
54
|
+
## In a `create_agent` agent
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from langchain.agents import create_agent
|
|
58
|
+
from vinc_langgraph import VincContextMiddleware
|
|
59
|
+
|
|
60
|
+
agent = create_agent("openai:gpt-5", middleware=[VincContextMiddleware()])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The middleware adds the block to the system message of each model call. An agent that uses tools calls the model several times per question, so the answer is kept for 60 seconds (`cache_seconds`) instead of spending a Vinc call each time.
|
|
64
|
+
|
|
65
|
+
## Tools the model can call
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from vinc_langgraph import create_vinc_tools
|
|
69
|
+
|
|
70
|
+
tools = create_vinc_tools(VincClient()) # vinc_brief and vinc_search, read only
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Recording, after a person approved
|
|
74
|
+
|
|
75
|
+
Nothing in this package writes on its own. Put `record_episode` in the node after an `interrupt()`, and give only that node a `vinc_sk_` key. `examples/approve_then_record.py` is a complete graph:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
context -> review -> approve (interrupt) -> record
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# vinc-langgraph
|
|
2
|
+
|
|
3
|
+
[Vinc](https://vincs.io) for [LangGraph](https://langchain-ai.github.io/langgraph/) and LangChain agents. Vinc is a knowledge graph you share with AI: the decisions, records and documents your team wrote, with the reasons attached. This package brings the relevant part of it into your graph nodes and model calls.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vinc-langgraph # node helpers and tools
|
|
9
|
+
pip install "vinc-langgraph[agents]" # plus the middleware for create_agent
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Set `VINC_API_KEY` to a member key. A `vinc_ro_` key is enough for everything except `record_episode`.
|
|
13
|
+
|
|
14
|
+
## In a graph node
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from vinc_client import VincClient
|
|
18
|
+
from vinc_langgraph import build_system_message, get_vinc_context
|
|
19
|
+
|
|
20
|
+
vinc = VincClient()
|
|
21
|
+
|
|
22
|
+
def review(state):
|
|
23
|
+
block = get_vinc_context(vinc, state["messages"]) # newest human message
|
|
24
|
+
system = build_system_message(block, "You review design changes.")
|
|
25
|
+
return {"messages": [llm.invoke([system, *state["messages"]])]}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`get_vinc_context` makes one call, two when nothing matched or several nodes tied (it then widens with a search). It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the node goes on without it. A wrong key or space raises.
|
|
29
|
+
|
|
30
|
+
## In a `create_agent` agent
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from langchain.agents import create_agent
|
|
34
|
+
from vinc_langgraph import VincContextMiddleware
|
|
35
|
+
|
|
36
|
+
agent = create_agent("openai:gpt-5", middleware=[VincContextMiddleware()])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The middleware adds the block to the system message of each model call. An agent that uses tools calls the model several times per question, so the answer is kept for 60 seconds (`cache_seconds`) instead of spending a Vinc call each time.
|
|
40
|
+
|
|
41
|
+
## Tools the model can call
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from vinc_langgraph import create_vinc_tools
|
|
45
|
+
|
|
46
|
+
tools = create_vinc_tools(VincClient()) # vinc_brief and vinc_search, read only
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Recording, after a person approved
|
|
50
|
+
|
|
51
|
+
Nothing in this package writes on its own. Put `record_episode` in the node after an `interrupt()`, and give only that node a `vinc_sk_` key. `examples/approve_then_record.py` is a complete graph:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
context -> review -> approve (interrupt) -> record
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A review graph that reads Vinc, stops for a person, and records only what was approved.
|
|
2
|
+
|
|
3
|
+
context -> review -> approve (interrupt) -> record
|
|
4
|
+
|
|
5
|
+
Run it against your own graph with two keys: a read-only key for the reading
|
|
6
|
+
nodes and a write key for the recording node only.
|
|
7
|
+
|
|
8
|
+
VINC_API_KEY=vinc_ro_... VINC_WRITE_KEY=vinc_sk_... python approve_then_record.py
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, TypedDict
|
|
14
|
+
|
|
15
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
16
|
+
from langgraph.graph import END, START, StateGraph
|
|
17
|
+
from langgraph.types import Command, interrupt
|
|
18
|
+
from vinc_client import VincClient
|
|
19
|
+
|
|
20
|
+
from vinc_langgraph import get_vinc_context, record_episode
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReviewState(TypedDict, total=False):
|
|
24
|
+
question: str
|
|
25
|
+
context: str | None
|
|
26
|
+
draft: str
|
|
27
|
+
approved: bool
|
|
28
|
+
about: list[str]
|
|
29
|
+
written: dict[str, Any] | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_graph(reader: VincClient, writer: VincClient, review=None):
|
|
33
|
+
"""``review`` turns (question, context) into a draft; a model call in real use."""
|
|
34
|
+
review = review or (lambda question, context: f"Reviewed: {question}")
|
|
35
|
+
|
|
36
|
+
def context_node(state: ReviewState) -> ReviewState:
|
|
37
|
+
return {"context": get_vinc_context(reader, state["question"])}
|
|
38
|
+
|
|
39
|
+
def review_node(state: ReviewState) -> ReviewState:
|
|
40
|
+
return {"draft": review(state["question"], state.get("context"))}
|
|
41
|
+
|
|
42
|
+
def approve_node(state: ReviewState) -> ReviewState:
|
|
43
|
+
# 사람이 재개할 때까지 여기서 멈춘다. 재개 값이 참일 때만 기록한다.
|
|
44
|
+
return {"approved": bool(interrupt({"draft": state["draft"]}))}
|
|
45
|
+
|
|
46
|
+
def record_node(state: ReviewState) -> ReviewState:
|
|
47
|
+
if not state.get("approved"):
|
|
48
|
+
return {"written": None}
|
|
49
|
+
return {"written": record_episode(writer, state["draft"], about=state.get("about") or [])}
|
|
50
|
+
|
|
51
|
+
graph = StateGraph(ReviewState)
|
|
52
|
+
graph.add_node("context", context_node)
|
|
53
|
+
graph.add_node("review", review_node)
|
|
54
|
+
graph.add_node("approve", approve_node)
|
|
55
|
+
graph.add_node("record", record_node)
|
|
56
|
+
graph.add_edge(START, "context")
|
|
57
|
+
graph.add_edge("context", "review")
|
|
58
|
+
graph.add_edge("review", "approve")
|
|
59
|
+
graph.add_edge("approve", "record")
|
|
60
|
+
graph.add_edge("record", END)
|
|
61
|
+
return graph.compile(checkpointer=InMemorySaver())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
app = build_graph(VincClient(), VincClient(os.environ["VINC_WRITE_KEY"]))
|
|
66
|
+
config = {"configurable": {"thread_id": "review-1"}}
|
|
67
|
+
paused = app.invoke({"question": "Does the new secondary button meet contrast on dark?",
|
|
68
|
+
"about": []}, config)
|
|
69
|
+
print("waiting for approval:", paused["__interrupt__"][0].value)
|
|
70
|
+
print(app.invoke(Command(resume=input("approve? [y/N] ").lower() == "y"), config))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vinc-langgraph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Vinc for LangGraph and LangChain agents: bring decisions and records from your knowledge graph into graph nodes and model calls, and record an episode only when you choose to."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Vinculums" }]
|
|
13
|
+
keywords = ["vinc", "langgraph", "langchain", "middleware", "knowledge-graph", "memory"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["vinc-client>=0.1,<0.2", "langgraph>=1.0", "langchain-core>=1.0"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
agents = ["langchain>=1.0"]
|
|
24
|
+
test = ["pytest>=8", "langchain>=1.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://vincs.io"
|
|
28
|
+
Documentation = "https://vincs.io/docs/"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/vinc_langgraph"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Vinc for LangGraph and LangChain agents.
|
|
2
|
+
|
|
3
|
+
Design and scope: [[vinc.plan.framework-integration-packages-2026-09-28]].
|
|
4
|
+
``VincContextMiddleware`` needs the ``agents`` extra and is imported lazily.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .helpers import (aget_vinc_context, arecord_episode, build_system_message, # noqa: E402
|
|
13
|
+
create_vinc_tools, get_vinc_context, last_user_text, record_episode)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name: str) -> Any:
|
|
17
|
+
# 미들웨어는 langchain(agents 추가 설치)이 있어야 한다. 노드 헬퍼만 쓰는 사람에게
|
|
18
|
+
# 그 의존성을 강요하지 않으려고 이름을 처음 쓸 때 가져온다.
|
|
19
|
+
if name == "VincContextMiddleware":
|
|
20
|
+
from .middleware import VincContextMiddleware
|
|
21
|
+
return VincContextMiddleware
|
|
22
|
+
raise AttributeError(name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["__version__", "get_vinc_context", "aget_vinc_context", "build_system_message",
|
|
26
|
+
"create_vinc_tools", "record_episode", "arecord_episode", "last_user_text",
|
|
27
|
+
"VincContextMiddleware"]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Helpers for graph nodes: read context, build a system message, expose read tools, record.
|
|
2
|
+
|
|
3
|
+
None of these writes on its own. ``record_episode`` writes because you call it,
|
|
4
|
+
which in a graph belongs in a node after an ``interrupt()`` a person resumed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Iterable, Sequence
|
|
9
|
+
|
|
10
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
11
|
+
from langchain_core.tools import StructuredTool
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
from vinc_client import (AsyncVincClient, VincClient, VincQuotaExceeded, VincUnavailable,
|
|
14
|
+
format_brief, format_search)
|
|
15
|
+
from vinc_client.context import DEFAULT_MAX_CHARS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def last_user_text(messages: str | Sequence[Any]) -> str:
|
|
19
|
+
"""The newest human message's text; a plain string is returned as is."""
|
|
20
|
+
if isinstance(messages, str):
|
|
21
|
+
return messages.strip()
|
|
22
|
+
for message in reversed(list(messages or [])):
|
|
23
|
+
if isinstance(message, HumanMessage) or getattr(message, "type", "") == "human":
|
|
24
|
+
text = getattr(message, "text", None)
|
|
25
|
+
# 1.x 에서 .text 는 속성이다(호출하면 폐기 경고). 문자열이 아닌 호출체일 때만 부른다.
|
|
26
|
+
if callable(text) and not isinstance(text, str):
|
|
27
|
+
text = text()
|
|
28
|
+
return str(text if text is not None else message.content).strip()
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_vinc_context(client: VincClient, messages: str | Sequence[Any], *,
|
|
33
|
+
search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
|
|
34
|
+
max_excerpts: int = 3) -> str | None:
|
|
35
|
+
"""A fenced context block for the newest human message, or ``None``.
|
|
36
|
+
|
|
37
|
+
``None`` also when Vinc is unavailable or the quota is spent; a key or space
|
|
38
|
+
problem raises.
|
|
39
|
+
"""
|
|
40
|
+
return client.get_context(last_user_text(messages), search_fallback=search_fallback,
|
|
41
|
+
max_chars=max_chars, max_excerpts=max_excerpts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def aget_vinc_context(client: AsyncVincClient, messages: str | Sequence[Any], *,
|
|
45
|
+
search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
|
|
46
|
+
max_excerpts: int = 3) -> str | None:
|
|
47
|
+
"""Async :func:`get_vinc_context`."""
|
|
48
|
+
return await client.get_context(last_user_text(messages), search_fallback=search_fallback,
|
|
49
|
+
max_chars=max_chars, max_excerpts=max_excerpts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_system_message(block: str | None, base: SystemMessage | str | None = None
|
|
53
|
+
) -> SystemMessage | None:
|
|
54
|
+
"""Append ``block`` to ``base`` (or start one). Returns ``base`` unchanged when there
|
|
55
|
+
is no block, and ``None`` when there is neither."""
|
|
56
|
+
if isinstance(base, str):
|
|
57
|
+
base = SystemMessage(content=base)
|
|
58
|
+
if not block:
|
|
59
|
+
return base
|
|
60
|
+
if base is None:
|
|
61
|
+
return SystemMessage(content=block)
|
|
62
|
+
return SystemMessage(content=[*base.content_blocks, {"type": "text", "text": block}])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _BriefArgs(BaseModel):
|
|
66
|
+
query: str = Field(description="A question, or an exact node id such as decision:some-slug.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _SearchArgs(BaseModel):
|
|
70
|
+
query: str = Field(description="What to look for.")
|
|
71
|
+
limit: int = Field(default=5, ge=1, le=20, description="How many matches of each kind.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_DOWN = "Vinc is not available right now ({code}); answer without it."
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_vinc_tools(client: VincClient | None = None,
|
|
78
|
+
async_client: AsyncVincClient | None = None, *,
|
|
79
|
+
max_chars: int = DEFAULT_MAX_CHARS) -> list[StructuredTool]:
|
|
80
|
+
"""Two read-only tools, ``vinc_brief`` and ``vinc_search``.
|
|
81
|
+
|
|
82
|
+
Pass a sync client, an async client, or both; each tool gets the paths it
|
|
83
|
+
has a client for. Nothing here can write.
|
|
84
|
+
"""
|
|
85
|
+
if client is None and async_client is None:
|
|
86
|
+
raise ValueError("pass a VincClient, an AsyncVincClient, or both")
|
|
87
|
+
|
|
88
|
+
def brief(query: str) -> str:
|
|
89
|
+
try:
|
|
90
|
+
return (format_brief(client.brief(query), max_chars=max_chars) # type: ignore[union-attr]
|
|
91
|
+
or "Vinc has no single node for that; try vinc_search.")
|
|
92
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
93
|
+
return _DOWN.format(code=exc.code)
|
|
94
|
+
|
|
95
|
+
async def abrief(query: str) -> str:
|
|
96
|
+
try:
|
|
97
|
+
return (format_brief(await async_client.brief(query), max_chars=max_chars) # type: ignore[union-attr]
|
|
98
|
+
or "Vinc has no single node for that; try vinc_search.")
|
|
99
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
100
|
+
return _DOWN.format(code=exc.code)
|
|
101
|
+
|
|
102
|
+
def search(query: str, limit: int = 5) -> str:
|
|
103
|
+
try:
|
|
104
|
+
return (format_search(client.search(query, limit=limit), # type: ignore[union-attr]
|
|
105
|
+
max_chars=max_chars, limit=limit) or "No matches in Vinc.")
|
|
106
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
107
|
+
return _DOWN.format(code=exc.code)
|
|
108
|
+
|
|
109
|
+
async def asearch(query: str, limit: int = 5) -> str:
|
|
110
|
+
try:
|
|
111
|
+
return (format_search(await async_client.search(query, limit=limit), # type: ignore[union-attr]
|
|
112
|
+
max_chars=max_chars, limit=limit) or "No matches in Vinc.")
|
|
113
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
114
|
+
return _DOWN.format(code=exc.code)
|
|
115
|
+
|
|
116
|
+
return [
|
|
117
|
+
StructuredTool.from_function(
|
|
118
|
+
func=brief if client else None, coroutine=abrief if async_client else None,
|
|
119
|
+
name="vinc_brief", args_schema=_BriefArgs,
|
|
120
|
+
description="Look up one node in the user's Vinc knowledge graph: a decision, "
|
|
121
|
+
"record or concept with its summary, document excerpts and relations."),
|
|
122
|
+
StructuredTool.from_function(
|
|
123
|
+
func=search if client else None, coroutine=asearch if async_client else None,
|
|
124
|
+
name="vinc_search", args_schema=_SearchArgs,
|
|
125
|
+
description="Search the user's Vinc knowledge graph by text and meaning; returns "
|
|
126
|
+
"matching nodes and document passages with their ids."),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def record_episode(client: VincClient, title: str, *, summary: str = "",
|
|
131
|
+
about: Iterable[str] = (), date: str | None = None,
|
|
132
|
+
domain: str | None = None, tags: Iterable[str] | None = None,
|
|
133
|
+
space: str | None = None) -> dict[str, Any]:
|
|
134
|
+
"""Write one Episode about existing concepts or decisions (needs a ``vinc_sk_`` key).
|
|
135
|
+
|
|
136
|
+
Put it in the node that runs after a person resumed an ``interrupt()``.
|
|
137
|
+
"""
|
|
138
|
+
return client.record_episode(title, summary=summary, about=about, date=date,
|
|
139
|
+
domain=domain, tags=tags, space=space)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def arecord_episode(client: AsyncVincClient, title: str, *, summary: str = "",
|
|
143
|
+
about: Iterable[str] = (), date: str | None = None,
|
|
144
|
+
domain: str | None = None, tags: Iterable[str] | None = None,
|
|
145
|
+
space: str | None = None) -> dict[str, Any]:
|
|
146
|
+
"""Async :func:`record_episode`."""
|
|
147
|
+
return await client.record_episode(title, summary=summary, about=about, date=date,
|
|
148
|
+
domain=domain, tags=tags, space=space)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["last_user_text", "get_vinc_context", "aget_vinc_context",
|
|
152
|
+
"build_system_message", "create_vinc_tools", "record_episode", "arecord_episode"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Middleware for LangChain ``create_agent`` (which runs on LangGraph).
|
|
2
|
+
|
|
3
|
+
Needs the ``agents`` extra: ``pip install "vinc-langgraph[agents]"``.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
from langchain.agents.middleware import AgentMiddleware, ModelRequest
|
|
11
|
+
from vinc_client import AsyncVincClient, VincClient
|
|
12
|
+
from vinc_client.context import DEFAULT_MAX_CHARS
|
|
13
|
+
|
|
14
|
+
from .helpers import build_system_message, last_user_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VincContextMiddleware(AgentMiddleware):
|
|
18
|
+
"""Adds Vinc context to the system message of each model call.
|
|
19
|
+
|
|
20
|
+
An agent that calls tools calls the model several times for one question.
|
|
21
|
+
The answer for a question is kept for ``cache_seconds`` so those calls do not
|
|
22
|
+
each spend a Vinc call. Nothing is written: this middleware only reads.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: VincClient | None = None,
|
|
26
|
+
async_client: AsyncVincClient | None = None, *, api_key: str | None = None,
|
|
27
|
+
base_url: str | None = None, space: str | None = None,
|
|
28
|
+
search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
|
|
29
|
+
max_excerpts: int = 3, cache_seconds: float = 60.0) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._config = {"api_key": api_key, "base_url": base_url, "space": space}
|
|
32
|
+
self._client = client
|
|
33
|
+
self._async_client = async_client
|
|
34
|
+
self.search_fallback = search_fallback
|
|
35
|
+
self.max_chars = max_chars
|
|
36
|
+
self.max_excerpts = max_excerpts
|
|
37
|
+
self.cache_seconds = cache_seconds
|
|
38
|
+
self._cache: dict[str, tuple[float, str | None]] = {}
|
|
39
|
+
|
|
40
|
+
# 클라이언트는 쓰는 쪽(동기·비동기)만 만든다 — 둘 다 만들면 연결 풀이 두 벌 생긴다.
|
|
41
|
+
@property
|
|
42
|
+
def client(self) -> VincClient:
|
|
43
|
+
if self._client is None:
|
|
44
|
+
self._client = VincClient(self._config["api_key"], base_url=self._config["base_url"],
|
|
45
|
+
space=self._config["space"])
|
|
46
|
+
return self._client
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def async_client(self) -> AsyncVincClient:
|
|
50
|
+
if self._async_client is None:
|
|
51
|
+
self._async_client = AsyncVincClient(self._config["api_key"],
|
|
52
|
+
base_url=self._config["base_url"],
|
|
53
|
+
space=self._config["space"])
|
|
54
|
+
return self._async_client
|
|
55
|
+
|
|
56
|
+
def _cached(self, query: str) -> tuple[bool, str | None]:
|
|
57
|
+
hit = self._cache.get(query)
|
|
58
|
+
if hit and time.monotonic() - hit[0] < self.cache_seconds:
|
|
59
|
+
return True, hit[1]
|
|
60
|
+
return False, None
|
|
61
|
+
|
|
62
|
+
def _remember(self, query: str, block: str | None) -> None:
|
|
63
|
+
if len(self._cache) >= 64:
|
|
64
|
+
self._cache.pop(next(iter(self._cache)))
|
|
65
|
+
self._cache[query] = (time.monotonic(), block)
|
|
66
|
+
|
|
67
|
+
def _with_block(self, request: ModelRequest, block: str | None) -> ModelRequest:
|
|
68
|
+
if not block:
|
|
69
|
+
return request
|
|
70
|
+
if hasattr(request, "system_message"):
|
|
71
|
+
return request.override(
|
|
72
|
+
system_message=build_system_message(block, request.system_message))
|
|
73
|
+
# langchain 1.0 초기판의 ModelRequest 는 문자열 system_prompt 만 갖는다.
|
|
74
|
+
base = getattr(request, "system_prompt", None) or ""
|
|
75
|
+
return request.override(system_prompt=f"{base}\n\n{block}" if base else block)
|
|
76
|
+
|
|
77
|
+
def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], Any]) -> Any:
|
|
78
|
+
query = last_user_text(request.messages)
|
|
79
|
+
found, block = self._cached(query)
|
|
80
|
+
if not found:
|
|
81
|
+
block = self.client.get_context(query, search_fallback=self.search_fallback,
|
|
82
|
+
max_chars=self.max_chars,
|
|
83
|
+
max_excerpts=self.max_excerpts)
|
|
84
|
+
self._remember(query, block)
|
|
85
|
+
return handler(self._with_block(request, block))
|
|
86
|
+
|
|
87
|
+
async def awrap_model_call(self, request: ModelRequest,
|
|
88
|
+
handler: Callable[[ModelRequest], Awaitable[Any]]) -> Any:
|
|
89
|
+
query = last_user_text(request.messages)
|
|
90
|
+
found, block = self._cached(query)
|
|
91
|
+
if not found:
|
|
92
|
+
block = await self.async_client.get_context(
|
|
93
|
+
query, search_fallback=self.search_fallback, max_chars=self.max_chars,
|
|
94
|
+
max_excerpts=self.max_excerpts)
|
|
95
|
+
self._remember(query, block)
|
|
96
|
+
return await handler(self._with_block(request, block))
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
13
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
14
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
15
|
+
from langgraph.types import Command
|
|
16
|
+
|
|
17
|
+
from vinc_client import AsyncVincClient, VincAuthError, VincClient
|
|
18
|
+
from vinc_langgraph import (aget_vinc_context, build_system_message, create_vinc_tools,
|
|
19
|
+
get_vinc_context, last_user_text, record_episode)
|
|
20
|
+
|
|
21
|
+
RO_KEY = "vinc_ro_" + "a" * 32
|
|
22
|
+
SK_KEY = "vinc_sk_" + "b" * 32
|
|
23
|
+
RESOLVED = {
|
|
24
|
+
"status": "resolved",
|
|
25
|
+
"node": {"id": "decision:tokens-are-oklch", "title": "Colour tokens are stored as OKLCH"},
|
|
26
|
+
"one_liner": "Tokens keep OKLCH so contrast can be checked per theme.",
|
|
27
|
+
"grounds": ["decision:tokens-are-oklch"],
|
|
28
|
+
}
|
|
29
|
+
DOWN = (503, {"error": {"code": "upstream_unavailable", "message": "down"}})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Routes:
|
|
33
|
+
def __init__(self, routes: dict[tuple[str, str], Any]):
|
|
34
|
+
self.routes = routes
|
|
35
|
+
self.seen: list[httpx.Request] = []
|
|
36
|
+
|
|
37
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
38
|
+
self.seen.append(request)
|
|
39
|
+
answer = self.routes.get((request.method, request.url.path))
|
|
40
|
+
if answer is None:
|
|
41
|
+
return httpx.Response(404, json={"error": {"code": "route_not_found", "message": ""}})
|
|
42
|
+
status, body = answer if isinstance(answer, tuple) else (200, answer)
|
|
43
|
+
return httpx.Response(status, json=body)
|
|
44
|
+
|
|
45
|
+
def paths(self) -> list[str]:
|
|
46
|
+
return [r.url.path for r in self.seen]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sync_client(routes: Routes, key: str = RO_KEY) -> VincClient:
|
|
50
|
+
return VincClient(key, transport=httpx.MockTransport(routes))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def async_client(routes: Routes, key: str = RO_KEY) -> AsyncVincClient:
|
|
54
|
+
return AsyncVincClient(key, transport=httpx.MockTransport(routes))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RecordingModel(BaseChatModel):
|
|
58
|
+
"""Answers "ok" and keeps every message list it was given."""
|
|
59
|
+
|
|
60
|
+
calls: list = []
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def _llm_type(self) -> str:
|
|
64
|
+
return "recording"
|
|
65
|
+
|
|
66
|
+
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
|
|
67
|
+
self.calls.append(list(messages))
|
|
68
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
|
69
|
+
|
|
70
|
+
def system_text(self) -> str:
|
|
71
|
+
first = self.calls[-1][0]
|
|
72
|
+
return first.text if isinstance(first, SystemMessage) else ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── 헬퍼 ───────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def test_last_user_text_takes_the_newest_human_message():
|
|
78
|
+
msgs = [HumanMessage("first"), AIMessage("answer"), HumanMessage("the follow-up")]
|
|
79
|
+
assert last_user_text(msgs) == "the follow-up"
|
|
80
|
+
assert last_user_text(" plain ") == "plain"
|
|
81
|
+
assert last_user_text([AIMessage("only ai")]) == ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_get_vinc_context_sync_and_async():
|
|
85
|
+
routes = Routes({("POST", "/v1/brief"): RESOLVED})
|
|
86
|
+
assert "decision:tokens-are-oklch" in get_vinc_context(sync_client(routes),
|
|
87
|
+
[HumanMessage("why oklch tokens")])
|
|
88
|
+
block = asyncio.run(aget_vinc_context(async_client(routes), "why oklch tokens"))
|
|
89
|
+
assert "decision:tokens-are-oklch" in block
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_context_is_none_when_vinc_is_down():
|
|
93
|
+
routes = Routes({("POST", "/v1/brief"): DOWN})
|
|
94
|
+
assert get_vinc_context(sync_client(routes), "why oklch tokens") is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_build_system_message_appends_and_keeps():
|
|
98
|
+
assert build_system_message(None) is None
|
|
99
|
+
base = SystemMessage(content="You review design changes.")
|
|
100
|
+
assert build_system_message(None, base) is base
|
|
101
|
+
fresh = build_system_message("BLOCK")
|
|
102
|
+
assert fresh.text == "BLOCK"
|
|
103
|
+
joined = build_system_message("BLOCK", "You review design changes.")
|
|
104
|
+
assert "You review design changes." in joined.text and "BLOCK" in joined.text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── 도구 ───────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def test_tools_are_read_only_and_work_sync_and_async():
|
|
110
|
+
routes = Routes({("POST", "/v1/brief"): RESOLVED,
|
|
111
|
+
("POST", "/v1/search"): {"rows": [], "node_hits": [
|
|
112
|
+
{"id": "concept:spacing", "title": "Spacing", "kind": "concept"}]}})
|
|
113
|
+
tools = {t.name: t for t in create_vinc_tools(sync_client(routes), async_client(routes))}
|
|
114
|
+
assert sorted(tools) == ["vinc_brief", "vinc_search"]
|
|
115
|
+
assert "decision:tokens-are-oklch" in tools["vinc_brief"].invoke({"query": "tokens"})
|
|
116
|
+
out = asyncio.run(tools["vinc_search"].ainvoke({"query": "spacing", "limit": 2}))
|
|
117
|
+
assert "concept:spacing" in out and json.loads(routes.seen[-1].content)["limit"] == 2
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_tools_degrade_to_a_sentence_when_vinc_is_down():
|
|
121
|
+
routes = Routes({("POST", "/v1/brief"): DOWN})
|
|
122
|
+
brief = {t.name: t for t in create_vinc_tools(sync_client(routes))}["vinc_brief"]
|
|
123
|
+
assert "not available" in brief.invoke({"query": "tokens"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_tools_need_a_client():
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
create_vinc_tools()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── 쓰기 ───────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def test_record_episode_needs_a_write_key():
|
|
134
|
+
routes = Routes({})
|
|
135
|
+
with pytest.raises(VincAuthError):
|
|
136
|
+
record_episode(sync_client(routes), "Merged", about=["decision:tokens-are-oklch"])
|
|
137
|
+
assert routes.seen == []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── create_agent 미들웨어 ──────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def make_agent(middleware):
|
|
143
|
+
from langchain.agents import create_agent
|
|
144
|
+
model = RecordingModel()
|
|
145
|
+
model.calls = []
|
|
146
|
+
return create_agent(model, system_prompt="You review design changes.",
|
|
147
|
+
middleware=[middleware]), model
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_middleware_puts_the_block_in_the_system_message():
|
|
151
|
+
from vinc_langgraph import VincContextMiddleware
|
|
152
|
+
routes = Routes({("POST", "/v1/brief"): RESOLVED})
|
|
153
|
+
agent, model = make_agent(VincContextMiddleware(sync_client(routes)))
|
|
154
|
+
out = agent.invoke({"messages": [HumanMessage("Why are colour tokens OKLCH?")]})
|
|
155
|
+
assert out["messages"][-1].content == "ok"
|
|
156
|
+
system = model.system_text()
|
|
157
|
+
assert "You review design changes." in system and "decision:tokens-are-oklch" in system
|
|
158
|
+
assert routes.paths() == ["/v1/brief"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_middleware_reuses_the_answer_for_the_same_question():
|
|
162
|
+
from vinc_langgraph import VincContextMiddleware
|
|
163
|
+
routes = Routes({("POST", "/v1/brief"): RESOLVED})
|
|
164
|
+
agent, _ = make_agent(VincContextMiddleware(sync_client(routes)))
|
|
165
|
+
for _ in range(2):
|
|
166
|
+
agent.invoke({"messages": [HumanMessage("Why are colour tokens OKLCH?")]})
|
|
167
|
+
assert routes.paths() == ["/v1/brief"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_middleware_async_path_and_vinc_down():
|
|
171
|
+
from vinc_langgraph import VincContextMiddleware
|
|
172
|
+
routes = Routes({("POST", "/v1/brief"): DOWN})
|
|
173
|
+
agent, model = make_agent(VincContextMiddleware(async_client=async_client(routes)))
|
|
174
|
+
out = asyncio.run(agent.ainvoke({"messages": [HumanMessage("why are tokens oklch")]}))
|
|
175
|
+
assert out["messages"][-1].content == "ok"
|
|
176
|
+
assert "DATA" not in model.system_text()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── 예제 그래프: interrupt 로 멈추고, 승인한 것만 쓴다 ─────────────────────────
|
|
180
|
+
|
|
181
|
+
def load_example():
|
|
182
|
+
path = Path(__file__).resolve().parents[1] / "examples" / "approve_then_record.py"
|
|
183
|
+
spec = importlib.util.spec_from_file_location("approve_then_record", path)
|
|
184
|
+
module = importlib.util.module_from_spec(spec)
|
|
185
|
+
# 상태 TypedDict 의 주석을 LangGraph 가 모듈 전역으로 푼다 — 등록 안 하면 못 찾는다.
|
|
186
|
+
sys.modules[spec.name] = module
|
|
187
|
+
spec.loader.exec_module(module)
|
|
188
|
+
return module
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize("approved", [True, False])
|
|
192
|
+
def test_example_graph_writes_only_after_approval(approved):
|
|
193
|
+
reads = Routes({("POST", "/v1/brief"): RESOLVED})
|
|
194
|
+
writes = Routes({("GET", "/v1/nodes/decision:tokens-are-oklch"): RESOLVED,
|
|
195
|
+
("POST", "/v1/fragments"): {"warnings": [], "stored": []}})
|
|
196
|
+
app = load_example().build_graph(sync_client(reads), sync_client(writes, SK_KEY))
|
|
197
|
+
config = {"configurable": {"thread_id": f"t-{approved}"}}
|
|
198
|
+
paused = app.invoke({"question": "Does the secondary button meet contrast on dark?",
|
|
199
|
+
"about": ["decision:tokens-are-oklch"]}, config)
|
|
200
|
+
assert "__interrupt__" in paused and writes.seen == []
|
|
201
|
+
done = app.invoke(Command(resume=approved), config)
|
|
202
|
+
if approved:
|
|
203
|
+
assert writes.paths() == ["/v1/nodes/decision:tokens-are-oklch", "/v1/fragments"]
|
|
204
|
+
assert done["written"] is not None
|
|
205
|
+
else:
|
|
206
|
+
assert writes.seen == [] and done["written"] is None
|
|
207
|
+
assert reads.paths() == ["/v1/brief"]
|