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))
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any