vinc-agent-framework 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_agent_framework-0.1.0/.gitignore +52 -0
- vinc_agent_framework-0.1.0/PKG-INFO +80 -0
- vinc_agent_framework-0.1.0/README.md +60 -0
- vinc_agent_framework-0.1.0/constraints-min.txt +2 -0
- vinc_agent_framework-0.1.0/pyproject.toml +33 -0
- vinc_agent_framework-0.1.0/src/vinc_agent_framework/__init__.py +9 -0
- vinc_agent_framework-0.1.0/src/vinc_agent_framework/provider.py +125 -0
- vinc_agent_framework-0.1.0/src/vinc_agent_framework/py.typed +0 -0
- vinc_agent_framework-0.1.0/tests/test_provider.py +202 -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,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vinc-agent-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Vinc for Microsoft Agent Framework: bring decisions and records from your knowledge graph into each agent run, without writing anything on its own.
|
|
5
|
+
Project-URL: Homepage, https://vincs.io
|
|
6
|
+
Project-URL: Documentation, https://vincs.io/docs/
|
|
7
|
+
Author: Vinculums
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agent-framework,context-provider,knowledge-graph,memory,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: agent-framework-core>=1.8.1
|
|
16
|
+
Requires-Dist: vinc-client<0.2,>=0.1
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# vinc-agent-framework
|
|
22
|
+
|
|
23
|
+
[Vinc](https://vincs.io) for [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/). 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 each agent run.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install vinc-agent-framework
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Add context to every run
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from agent_framework import Agent
|
|
35
|
+
from agent_framework.openai import OpenAIChatClient
|
|
36
|
+
from vinc_agent_framework import VincContextProvider
|
|
37
|
+
|
|
38
|
+
vinc = VincContextProvider() # reads VINC_API_KEY; use a vinc_ro_ key
|
|
39
|
+
agent = Agent(
|
|
40
|
+
client=OpenAIChatClient(),
|
|
41
|
+
instructions="You review design changes.",
|
|
42
|
+
context_providers=[vinc],
|
|
43
|
+
)
|
|
44
|
+
await agent.run("Why are our colour tokens stored as OKLCH?")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Before each run the provider looks up the newest user message in Vinc and, when something matches, adds a block to the instructions. The block is marked as data, not instructions, and it cites node ids the model can quote back.
|
|
48
|
+
|
|
49
|
+
- One call per run; two when the lookup found nothing or several nodes tied, because it then widens with a search (`search_fallback=False` turns that off).
|
|
50
|
+
- If Vinc is unreachable or the daily limit is spent, the run goes on without the block. A wrong key or space raises.
|
|
51
|
+
- For a team's graph pass `space="<team id>"`.
|
|
52
|
+
|
|
53
|
+
## Let the model look things up
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
vinc = VincContextProvider(expose_tools=True)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
adds two read-only tools, `vinc_brief` and `vinc_search`. You can also build them yourself with `create_vinc_tools(client)`.
|
|
60
|
+
|
|
61
|
+
## Writing, on purpose
|
|
62
|
+
|
|
63
|
+
The provider never stores the conversation: `after_run` does nothing. Record what happened when a person approved it, with a `vinc_sk_` key:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from vinc_client import AsyncVincClient
|
|
67
|
+
from vinc_agent_framework import record_episode
|
|
68
|
+
|
|
69
|
+
writer = AsyncVincClient(api_key=WRITE_KEY)
|
|
70
|
+
await record_episode(
|
|
71
|
+
writer,
|
|
72
|
+
"Fixed contrast on the dark secondary button",
|
|
73
|
+
summary="text-secondary now passes 4.5:1 on the dark surface.",
|
|
74
|
+
about=["decision:tokens-are-oklch"],
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# vinc-agent-framework
|
|
2
|
+
|
|
3
|
+
[Vinc](https://vincs.io) for [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/). 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 each agent run.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vinc-agent-framework
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Add context to every run
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from agent_framework import Agent
|
|
15
|
+
from agent_framework.openai import OpenAIChatClient
|
|
16
|
+
from vinc_agent_framework import VincContextProvider
|
|
17
|
+
|
|
18
|
+
vinc = VincContextProvider() # reads VINC_API_KEY; use a vinc_ro_ key
|
|
19
|
+
agent = Agent(
|
|
20
|
+
client=OpenAIChatClient(),
|
|
21
|
+
instructions="You review design changes.",
|
|
22
|
+
context_providers=[vinc],
|
|
23
|
+
)
|
|
24
|
+
await agent.run("Why are our colour tokens stored as OKLCH?")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Before each run the provider looks up the newest user message in Vinc and, when something matches, adds a block to the instructions. The block is marked as data, not instructions, and it cites node ids the model can quote back.
|
|
28
|
+
|
|
29
|
+
- One call per run; two when the lookup found nothing or several nodes tied, because it then widens with a search (`search_fallback=False` turns that off).
|
|
30
|
+
- If Vinc is unreachable or the daily limit is spent, the run goes on without the block. A wrong key or space raises.
|
|
31
|
+
- For a team's graph pass `space="<team id>"`.
|
|
32
|
+
|
|
33
|
+
## Let the model look things up
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
vinc = VincContextProvider(expose_tools=True)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
adds two read-only tools, `vinc_brief` and `vinc_search`. You can also build them yourself with `create_vinc_tools(client)`.
|
|
40
|
+
|
|
41
|
+
## Writing, on purpose
|
|
42
|
+
|
|
43
|
+
The provider never stores the conversation: `after_run` does nothing. Record what happened when a person approved it, with a `vinc_sk_` key:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from vinc_client import AsyncVincClient
|
|
47
|
+
from vinc_agent_framework import record_episode
|
|
48
|
+
|
|
49
|
+
writer = AsyncVincClient(api_key=WRITE_KEY)
|
|
50
|
+
await record_episode(
|
|
51
|
+
writer,
|
|
52
|
+
"Fixed contrast on the dark secondary button",
|
|
53
|
+
summary="text-secondary now passes 4.5:1 on the dark surface.",
|
|
54
|
+
about=["decision:tokens-are-oklch"],
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vinc-agent-framework"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Vinc for Microsoft Agent Framework: bring decisions and records from your knowledge graph into each agent run, without writing anything on its own."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Vinculums" }]
|
|
13
|
+
keywords = ["vinc", "agent-framework", "context-provider", "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", "agent-framework-core>=1.8.1"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
test = ["pytest>=8"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://vincs.io"
|
|
27
|
+
Documentation = "https://vincs.io/docs/"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/vinc_agent_framework"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Vinc for Microsoft Agent Framework.
|
|
2
|
+
|
|
3
|
+
Design and scope: [[vinc.plan.framework-integration-packages-2026-09-28]].
|
|
4
|
+
"""
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .provider import VincContextProvider, create_vinc_tools, record_episode # noqa: E402
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__", "VincContextProvider", "create_vinc_tools", "record_episode"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""A context provider and read tools that connect an Agent Framework agent to Vinc.
|
|
2
|
+
|
|
3
|
+
Reading happens on every run, before the model is called. Writing happens only
|
|
4
|
+
when your code calls :func:`record_episode`; the provider never stores the
|
|
5
|
+
conversation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Any, Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext, tool
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
from vinc_client import (AsyncVincClient, VincQuotaExceeded, VincUnavailable, format_brief,
|
|
14
|
+
format_search)
|
|
15
|
+
from vinc_client.context import DEFAULT_MAX_CHARS
|
|
16
|
+
|
|
17
|
+
DEFAULT_SOURCE_ID = "vinc"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _last_user_text(messages: Sequence[Any]) -> str:
|
|
21
|
+
"""The text of the newest user message; the question the run is about."""
|
|
22
|
+
for message in reversed(list(messages or [])):
|
|
23
|
+
if str(getattr(message, "role", "")).lower().endswith("user"):
|
|
24
|
+
return (getattr(message, "text", "") or "").strip()
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_vinc_tools(client: AsyncVincClient, *,
|
|
29
|
+
max_chars: int = DEFAULT_MAX_CHARS) -> list[FunctionTool]:
|
|
30
|
+
"""Two read-only tools the model may call: ``vinc_brief`` and ``vinc_search``.
|
|
31
|
+
|
|
32
|
+
Nothing here can write. When Vinc is unavailable or the quota is spent the
|
|
33
|
+
tool answers with a sentence saying so instead of failing the run.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@tool(name="vinc_brief",
|
|
37
|
+
description="Look up one node in the user's Vinc knowledge graph: a decision, record "
|
|
38
|
+
"or concept with its summary, document excerpts and relations. Pass a "
|
|
39
|
+
"question or an exact node id such as decision:some-slug.")
|
|
40
|
+
async def vinc_brief(
|
|
41
|
+
query: Annotated[str, Field(description="A question, or an exact node id.")],
|
|
42
|
+
) -> str:
|
|
43
|
+
try:
|
|
44
|
+
return (format_brief(await client.brief(query), max_chars=max_chars)
|
|
45
|
+
or "Vinc has no single node for that; try vinc_search.")
|
|
46
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
47
|
+
return f"Vinc is not available right now ({exc.code}); answer without it."
|
|
48
|
+
|
|
49
|
+
@tool(name="vinc_search",
|
|
50
|
+
description="Search the user's Vinc knowledge graph by text and meaning; returns "
|
|
51
|
+
"matching nodes and document passages with their ids.")
|
|
52
|
+
async def vinc_search(
|
|
53
|
+
query: Annotated[str, Field(description="What to look for.")],
|
|
54
|
+
limit: Annotated[int, Field(description="How many matches of each kind.", ge=1,
|
|
55
|
+
le=20)] = 5,
|
|
56
|
+
) -> str:
|
|
57
|
+
try:
|
|
58
|
+
return (format_search(await client.search(query, limit=limit), max_chars=max_chars,
|
|
59
|
+
limit=limit)
|
|
60
|
+
or "No matches in Vinc.")
|
|
61
|
+
except (VincUnavailable, VincQuotaExceeded) as exc:
|
|
62
|
+
return f"Vinc is not available right now ({exc.code}); answer without it."
|
|
63
|
+
|
|
64
|
+
return [vinc_brief, vinc_search]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class VincContextProvider(ContextProvider):
|
|
68
|
+
"""Adds Vinc context to the instructions of every agent run.
|
|
69
|
+
|
|
70
|
+
``before_run`` briefs the newest user message and, when something matched,
|
|
71
|
+
appends a fenced block marked as data. With ``search_fallback`` (the default)
|
|
72
|
+
a tie or a miss is widened with a search, so a run costs one call, or two.
|
|
73
|
+
``after_run`` does nothing: unlike memory providers that store each turn,
|
|
74
|
+
this one never writes. Use :func:`record_episode` after a person approved
|
|
75
|
+
what happened.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, client: AsyncVincClient | None = None, *, api_key: str | None = None,
|
|
79
|
+
base_url: str | None = None, space: str | None = None,
|
|
80
|
+
source_id: str = DEFAULT_SOURCE_ID, search_fallback: bool = True,
|
|
81
|
+
max_chars: int = DEFAULT_MAX_CHARS, max_excerpts: int = 3,
|
|
82
|
+
min_query_chars: int = 3, expose_tools: bool = False) -> None:
|
|
83
|
+
super().__init__(source_id)
|
|
84
|
+
self.client = client or AsyncVincClient(api_key, base_url=base_url, space=space)
|
|
85
|
+
self._owns_client = client is None
|
|
86
|
+
self.search_fallback = search_fallback
|
|
87
|
+
self.max_chars = max_chars
|
|
88
|
+
self.max_excerpts = max_excerpts
|
|
89
|
+
self.min_query_chars = min_query_chars
|
|
90
|
+
self._tools = create_vinc_tools(self.client, max_chars=max_chars) if expose_tools else []
|
|
91
|
+
|
|
92
|
+
async def before_run(self, *, agent: Any, session: AgentSession, context: SessionContext,
|
|
93
|
+
state: dict[str, Any]) -> None:
|
|
94
|
+
block = await self.client.get_context(
|
|
95
|
+
_last_user_text(context.input_messages), search_fallback=self.search_fallback,
|
|
96
|
+
max_chars=self.max_chars, max_excerpts=self.max_excerpts,
|
|
97
|
+
min_query_chars=self.min_query_chars)
|
|
98
|
+
if block:
|
|
99
|
+
context.extend_instructions(self.source_id, block)
|
|
100
|
+
if self._tools:
|
|
101
|
+
context.extend_tools(self.source_id, self._tools)
|
|
102
|
+
|
|
103
|
+
async def after_run(self, *, agent: Any, session: AgentSession, context: SessionContext,
|
|
104
|
+
state: dict[str, Any]) -> None:
|
|
105
|
+
# 의도적으로 비어 있다. 대화를 그래프에 쓰지 않는다 — 쓰기는 record_episode 뿐.
|
|
106
|
+
# 근거: [[decision:framework-integrations-ship-zep-shaped-knowledge-packages-first-2026-09-28]]
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
async def aclose(self) -> None:
|
|
110
|
+
"""Close the HTTP client if this provider created it."""
|
|
111
|
+
if self._owns_client:
|
|
112
|
+
await self.client.aclose()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def record_episode(client: AsyncVincClient, title: str, *, summary: str = "",
|
|
116
|
+
about: Iterable[str] = (), date: str | None = None,
|
|
117
|
+
domain: str | None = None, tags: Iterable[str] | None = None,
|
|
118
|
+
space: str | None = None) -> dict[str, Any]:
|
|
119
|
+
"""Write one Episode about existing concepts or decisions. Needs a ``vinc_sk_`` key.
|
|
120
|
+
|
|
121
|
+
Call it after a person approved what the agent did; see
|
|
122
|
+
:meth:`vinc_client.AsyncVincClient.record_episode`.
|
|
123
|
+
"""
|
|
124
|
+
return await client.record_episode(title, summary=summary, about=about, date=date,
|
|
125
|
+
domain=domain, tags=tags, space=space)
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from agent_framework import Agent, BaseChatClient, ChatResponse, Message, SessionContext
|
|
10
|
+
|
|
11
|
+
from vinc_agent_framework import VincContextProvider, create_vinc_tools, record_episode
|
|
12
|
+
from vinc_client import AsyncVincClient, VincAuthError
|
|
13
|
+
|
|
14
|
+
RO_KEY = "vinc_ro_" + "a" * 32
|
|
15
|
+
SK_KEY = "vinc_sk_" + "b" * 32
|
|
16
|
+
|
|
17
|
+
RESOLVED = {
|
|
18
|
+
"status": "resolved",
|
|
19
|
+
"node": {"id": "decision:tokens-are-oklch", "title": "Colour tokens are stored as OKLCH"},
|
|
20
|
+
"one_liner": "Tokens keep OKLCH so contrast can be checked per theme.",
|
|
21
|
+
"grounds": ["decision:tokens-are-oklch"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def vinc(routes: dict[tuple[str, str], Any], key: str = RO_KEY) -> tuple[AsyncVincClient, list]:
|
|
26
|
+
seen: list[httpx.Request] = []
|
|
27
|
+
|
|
28
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
29
|
+
seen.append(request)
|
|
30
|
+
answer = routes.get((request.method, request.url.path))
|
|
31
|
+
if answer is None:
|
|
32
|
+
return httpx.Response(404, json={"error": {"code": "route_not_found", "message": ""}})
|
|
33
|
+
status, body = answer if isinstance(answer, tuple) else (200, answer)
|
|
34
|
+
return httpx.Response(status, json=body)
|
|
35
|
+
|
|
36
|
+
return AsyncVincClient(key, transport=httpx.MockTransport(handler)), seen
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RecordingChatClient(BaseChatClient):
|
|
40
|
+
"""Answers "ok" and keeps what the agent sent, so a test can read the instructions."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.calls: list[dict[str, Any]] = []
|
|
45
|
+
|
|
46
|
+
async def _inner_get_response(self, *, messages, stream, options, **kwargs): # type: ignore[override]
|
|
47
|
+
self.calls.append({"messages": list(messages), "options": dict(options)})
|
|
48
|
+
return ChatResponse(messages=Message("assistant", ["ok"]))
|
|
49
|
+
|
|
50
|
+
def sent_text(self) -> str:
|
|
51
|
+
call = self.calls[-1]
|
|
52
|
+
parts = [str(call["options"].get("instructions") or "")]
|
|
53
|
+
parts += [m.text or "" for m in call["messages"]]
|
|
54
|
+
return "\n".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def context_for(*messages: Message) -> SessionContext:
|
|
58
|
+
return SessionContext(input_messages=list(messages))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(coro):
|
|
62
|
+
return asyncio.run(coro)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_agent(agent: Agent, prompt: str):
|
|
66
|
+
"""실제 사용처럼 이벤트 루프 안에서 부른다. 루프 밖에서 agent.run() 을 만들어 넘기면
|
|
67
|
+
1.8.x 의 관측 코드가 컨텍스트 변수를 다른 컨텍스트에서 되돌리려다 실패한다."""
|
|
68
|
+
async def go():
|
|
69
|
+
return await agent.run(prompt)
|
|
70
|
+
return asyncio.run(go())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool_text(result: Any) -> str:
|
|
74
|
+
"""버전에 따라 invoke 는 문자열이나 Content 목록을 돌려준다(1.19 는 목록)."""
|
|
75
|
+
if isinstance(result, str):
|
|
76
|
+
return result
|
|
77
|
+
return "".join(getattr(part, "text", None) or str(part) for part in result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── before_run ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def test_before_run_adds_the_fenced_block_to_the_instructions():
|
|
83
|
+
client, seen = vinc({("POST", "/v1/brief"): RESOLVED})
|
|
84
|
+
provider = VincContextProvider(client)
|
|
85
|
+
ctx = context_for(Message("user", ["Why are colour tokens OKLCH?"]))
|
|
86
|
+
run(provider.before_run(agent=None, session=None, context=ctx, state={}))
|
|
87
|
+
assert len(ctx.instructions) == 1
|
|
88
|
+
assert "decision:tokens-are-oklch" in ctx.instructions[0] and "DATA" in ctx.instructions[0]
|
|
89
|
+
assert json.loads(seen[0].content)["query"] == "Why are colour tokens OKLCH?"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_the_newest_user_message_is_the_query():
|
|
93
|
+
client, seen = vinc({("POST", "/v1/brief"): RESOLVED})
|
|
94
|
+
ctx = context_for(Message("user", ["first question"]), Message("assistant", ["an answer"]),
|
|
95
|
+
Message("user", ["the follow-up question"]))
|
|
96
|
+
run(VincContextProvider(client).before_run(agent=None, session=None, context=ctx, state={}))
|
|
97
|
+
assert json.loads(seen[0].content)["query"] == "the follow-up question"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_nothing_matched_adds_nothing():
|
|
101
|
+
client, _ = vinc({("POST", "/v1/brief"): {"status": "none"},
|
|
102
|
+
("POST", "/v1/search"): {"rows": [], "node_hits": []}})
|
|
103
|
+
ctx = context_for(Message("user", ["nothing like this exists"]))
|
|
104
|
+
run(VincContextProvider(client).before_run(agent=None, session=None, context=ctx, state={}))
|
|
105
|
+
assert ctx.instructions == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_vinc_down_never_fails_the_run():
|
|
109
|
+
client, _ = vinc({("POST", "/v1/brief"): (503, {"error": {"code": "upstream_unavailable",
|
|
110
|
+
"message": "down"}})})
|
|
111
|
+
ctx = context_for(Message("user", ["why are tokens oklch"]))
|
|
112
|
+
run(VincContextProvider(client).before_run(agent=None, session=None, context=ctx, state={}))
|
|
113
|
+
assert ctx.instructions == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_a_key_problem_is_raised_not_hidden():
|
|
117
|
+
client, _ = vinc({("POST", "/v1/brief"): (401, {"error": {"code": "not_authenticated",
|
|
118
|
+
"message": ""}})})
|
|
119
|
+
ctx = context_for(Message("user", ["why are tokens oklch"]))
|
|
120
|
+
with pytest.raises(VincAuthError):
|
|
121
|
+
run(VincContextProvider(client).before_run(agent=None, session=None, context=ctx,
|
|
122
|
+
state={}))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_tools_are_added_only_when_asked_and_are_read_only():
|
|
126
|
+
client, _ = vinc({("POST", "/v1/brief"): RESOLVED})
|
|
127
|
+
plain = context_for(Message("user", ["why are tokens oklch"]))
|
|
128
|
+
run(VincContextProvider(client).before_run(agent=None, session=None, context=plain, state={}))
|
|
129
|
+
assert plain.tools == []
|
|
130
|
+
with_tools = context_for(Message("user", ["why are tokens oklch"]))
|
|
131
|
+
run(VincContextProvider(client, expose_tools=True).before_run(
|
|
132
|
+
agent=None, session=None, context=with_tools, state={}))
|
|
133
|
+
assert sorted(t.name for t in with_tools.tools) == ["vinc_brief", "vinc_search"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── after_run 은 아무것도 쓰지 않는다 ───────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def test_after_run_sends_nothing():
|
|
139
|
+
client, seen = vinc({}, key=SK_KEY)
|
|
140
|
+
ctx = context_for(Message("user", ["we merged the contrast fix"]))
|
|
141
|
+
run(VincContextProvider(client).after_run(agent=None, session=None, context=ctx, state={}))
|
|
142
|
+
assert seen == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── 실제 Agent 를 거쳐서 ───────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def test_an_agent_run_carries_the_block_to_the_model_and_writes_nothing():
|
|
148
|
+
client, seen = vinc({("POST", "/v1/brief"): RESOLVED})
|
|
149
|
+
chat = RecordingChatClient()
|
|
150
|
+
agent = Agent(chat, instructions="You review design changes.",
|
|
151
|
+
context_providers=[VincContextProvider(client)])
|
|
152
|
+
result = run_agent(agent, "Why are colour tokens OKLCH?")
|
|
153
|
+
assert result.text == "ok"
|
|
154
|
+
sent = chat.sent_text()
|
|
155
|
+
assert "You review design changes." in sent and "decision:tokens-are-oklch" in sent
|
|
156
|
+
assert [r.url.path for r in seen] == ["/v1/brief"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_an_agent_run_goes_on_when_vinc_is_down():
|
|
160
|
+
client, _ = vinc({("POST", "/v1/brief"): (502, {"error": {"code": "upstream_unavailable",
|
|
161
|
+
"message": ""}})})
|
|
162
|
+
chat = RecordingChatClient()
|
|
163
|
+
agent = Agent(chat, context_providers=[VincContextProvider(client)])
|
|
164
|
+
assert run_agent(agent, "why are tokens oklch").text == "ok"
|
|
165
|
+
assert "DATA" not in chat.sent_text()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── 도구 ───────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def test_brief_tool_returns_the_block_and_degrades_to_a_sentence():
|
|
171
|
+
client, _ = vinc({("POST", "/v1/brief"): RESOLVED})
|
|
172
|
+
brief_tool = {t.name: t for t in create_vinc_tools(client)}["vinc_brief"]
|
|
173
|
+
assert "decision:tokens-are-oklch" in tool_text(run(brief_tool.invoke(query="tokens")))
|
|
174
|
+
down, _ = vinc({("POST", "/v1/brief"): (429, {"error": {"code": "quota_exceeded",
|
|
175
|
+
"message": ""}})})
|
|
176
|
+
down_tool = {t.name: t for t in create_vinc_tools(down)}["vinc_brief"]
|
|
177
|
+
assert "not available" in tool_text(run(down_tool.invoke(query="tokens")))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_search_tool_passes_the_limit():
|
|
181
|
+
client, seen = vinc({("POST", "/v1/search"): {"rows": [], "node_hits": [
|
|
182
|
+
{"id": "concept:spacing", "title": "Spacing", "kind": "concept"}]}})
|
|
183
|
+
search_tool = {t.name: t for t in create_vinc_tools(client)}["vinc_search"]
|
|
184
|
+
assert "concept:spacing" in tool_text(run(search_tool.invoke(query="spacing", limit=3)))
|
|
185
|
+
assert json.loads(seen[0].content)["limit"] == 3
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── 쓰기 ───────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def test_record_episode_needs_a_write_key():
|
|
191
|
+
client, seen = vinc({})
|
|
192
|
+
with pytest.raises(VincAuthError):
|
|
193
|
+
run(record_episode(client, "Merged", about=["decision:tokens-are-oklch"]))
|
|
194
|
+
assert seen == []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_record_episode_writes_one_fragment():
|
|
198
|
+
client, seen = vinc({("GET", "/v1/nodes/decision:tokens-are-oklch"): RESOLVED,
|
|
199
|
+
("POST", "/v1/fragments"): {"warnings": [], "stored": []}}, key=SK_KEY)
|
|
200
|
+
run(record_episode(client, "Merged the contrast fix", about=["decision:tokens-are-oklch"],
|
|
201
|
+
date="2026-09-28"))
|
|
202
|
+
assert [r.url.path for r in seen] == ["/v1/nodes/decision:tokens-are-oklch", "/v1/fragments"]
|