vinc-langgraph 0.1.0__py3-none-any.whl
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,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))
|
vinc_langgraph/py.typed
ADDED
|
File without changes
|
|
@@ -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,7 @@
|
|
|
1
|
+
vinc_langgraph/__init__.py,sha256=zCNRdAuCXLv3Sago5AILYbBPG5FL-IuHC4wXvB0nkHM,1084
|
|
2
|
+
vinc_langgraph/helpers.py,sha256=F1VyKdMx2nJm2ASfj5xxuVUEvYQYeYW1ujRJCCnpG9Y,7389
|
|
3
|
+
vinc_langgraph/middleware.py,sha256=bmX-eylBbh76i1eEGpiw8cTYRX9HPAOCXs2snvMBYuk,4483
|
|
4
|
+
vinc_langgraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
vinc_langgraph-0.1.0.dist-info/METADATA,sha256=6lVN4VUOCwNiibBocC1Mzcojj2JOMCQ7KXgW1Tu3XLg,3172
|
|
6
|
+
vinc_langgraph-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
vinc_langgraph-0.1.0.dist-info/RECORD,,
|