pactspec-langchain 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.
@@ -0,0 +1,53 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+ /.test-build
16
+
17
+ # next.js
18
+ /.next/
19
+ /out/
20
+
21
+ # production
22
+ /build
23
+
24
+ # misc
25
+ .DS_Store
26
+ *.pem
27
+
28
+ # debug
29
+ npm-debug.log*
30
+ yarn-debug.log*
31
+ yarn-error.log*
32
+ .pnpm-debug.log*
33
+
34
+ # env files (can opt-in for committing if needed)
35
+ .env*
36
+
37
+ # vercel
38
+ .vercel
39
+
40
+ # typescript
41
+ *.tsbuildinfo
42
+ next-env.d.ts
43
+ .env.local
44
+ cli/node_modules/
45
+ cli/dist/
46
+ sdk/node_modules/
47
+ sdk/dist/
48
+ packages/*/dist/
49
+ packages/*/node_modules/
50
+ pactspec-py/dist/
51
+ pactspec-py/*.egg-info/
52
+ pactspec-py/__pycache__/
53
+ cli/test-source.*
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: pactspec-langchain
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for PactSpec — discover and invoke verified AI agents as LangChain tools
5
+ Project-URL: Homepage, https://pactspec.dev
6
+ Project-URL: Repository, https://github.com/pactspec/pactspec
7
+ Project-URL: Documentation, https://pactspec.dev/docs/integrations/langchain
8
+ Author-email: PactSpec <hello@pactspec.dev>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,langchain,pactspec,tools
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.25.0
23
+ Requires-Dist: langchain-core>=0.3.0
24
+ Requires-Dist: pactspec>=0.1.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pactspec-langchain
28
+
29
+ LangChain integration for [PactSpec](https://pactspec.dev) -- discover and invoke verified AI agents as LangChain tools with automatic pricing awareness.
30
+
31
+ Each skill in a PactSpec agent becomes a LangChain `BaseTool` with its name, description, input schema, and pricing information derived from the spec. The LLM sees pricing in the tool description and can make cost-aware decisions.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install pactspec-langchain
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ from pactspec_langchain import PactSpecToolkit
43
+
44
+ # Discover verified agents for a task
45
+ toolkit = PactSpecToolkit.from_registry(
46
+ query="invoice processing",
47
+ verified_only=True,
48
+ max_price=0.10,
49
+ )
50
+
51
+ # List what was found
52
+ for tool in toolkit.get_tools():
53
+ print(f"{tool.name}: {tool.description}")
54
+ ```
55
+
56
+ ## Using with a LangChain agent
57
+
58
+ ```python
59
+ from langchain_openai import ChatOpenAI
60
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
61
+ from langchain.agents import create_tool_calling_agent, AgentExecutor
62
+ from pactspec_langchain import PactSpecToolkit
63
+
64
+ # 1. Discover tools from the registry
65
+ toolkit = PactSpecToolkit.from_registry(
66
+ query="document analysis",
67
+ verified_only=True,
68
+ max_price=0.25,
69
+ )
70
+
71
+ # 2. Set up the LLM and prompt
72
+ llm = ChatOpenAI(model="gpt-4o")
73
+ prompt = ChatPromptTemplate.from_messages([
74
+ ("system", "You are a helpful assistant. Use the available tools to help the user. "
75
+ "Pay attention to tool costs and prefer cheaper options when quality is similar."),
76
+ ("human", "{input}"),
77
+ MessagesPlaceholder("agent_scratchpad"),
78
+ ])
79
+
80
+ # 3. Create the agent
81
+ agent = create_tool_calling_agent(llm, toolkit.get_tools(), prompt)
82
+ executor = AgentExecutor(agent=agent, tools=toolkit.get_tools(), verbose=True)
83
+
84
+ # 4. Run it
85
+ result = executor.invoke({"input": "Extract line items from this invoice: ..."})
86
+ print(result["output"])
87
+ ```
88
+
89
+ ## Load a specific agent
90
+
91
+ If you know the exact agent you want, load it by spec ID:
92
+
93
+ ```python
94
+ toolkit = PactSpecToolkit.from_agent(
95
+ "urn:pactspec:acme/invoice-agent@1.0.0",
96
+ auth_headers={"Authorization": "Bearer sk-your-api-key"},
97
+ )
98
+ ```
99
+
100
+ ## Filtering
101
+
102
+ ### By price
103
+
104
+ ```python
105
+ # Only tools that cost at most $0.05 per invocation
106
+ toolkit = PactSpecToolkit.from_registry(
107
+ query="translation",
108
+ max_price=0.05,
109
+ )
110
+ ```
111
+
112
+ ### By pricing model
113
+
114
+ ```python
115
+ # Only free tools
116
+ toolkit = PactSpecToolkit.from_registry(
117
+ query="summarization",
118
+ pricing_model="free",
119
+ )
120
+
121
+ # Only per-token pricing
122
+ toolkit = PactSpecToolkit.from_registry(
123
+ query="summarization",
124
+ pricing_model="per-token",
125
+ )
126
+ ```
127
+
128
+ ### Verified agents only
129
+
130
+ ```python
131
+ # Only agents that have passed PactSpec verification
132
+ toolkit = PactSpecToolkit.from_registry(
133
+ query="code review",
134
+ verified_only=True,
135
+ )
136
+ ```
137
+
138
+ ### Combining filters
139
+
140
+ ```python
141
+ toolkit = PactSpecToolkit.from_registry(
142
+ query="medical coding",
143
+ verified_only=True,
144
+ max_price=0.10,
145
+ pricing_model="per-invocation",
146
+ )
147
+ ```
148
+
149
+ ## How pricing appears in tool descriptions
150
+
151
+ Each tool's description includes pricing and verification status so the LLM can reason about costs:
152
+
153
+ ```
154
+ Extract line items from invoices | Cost: 0.05 USD/per-invocation via stripe | [Verified] | Agent: InvoiceBot v2.1.0
155
+ ```
156
+
157
+ For free tools:
158
+
159
+ ```
160
+ Summarize text documents | Cost: Free | Agent: SummaryAgent v1.0.0
161
+ ```
162
+
163
+ ## Authentication
164
+
165
+ Pass headers for authenticated agent endpoints:
166
+
167
+ ```python
168
+ toolkit = PactSpecToolkit.from_registry(
169
+ query="invoice processing",
170
+ auth_headers={
171
+ "Authorization": "Bearer sk-your-key",
172
+ "X-API-Key": "your-api-key",
173
+ },
174
+ )
175
+ ```
176
+
177
+ ## Using pre-fetched agents
178
+
179
+ If you've already fetched agents via the PactSpec Python SDK, avoid a second network call:
180
+
181
+ ```python
182
+ from pactspec import PactSpecClient
183
+ from pactspec_langchain import PactSpecToolkit
184
+
185
+ client = PactSpecClient()
186
+ result = client.search(q="translation", verified_only=True)
187
+
188
+ # Pass agent records directly
189
+ toolkit = PactSpecToolkit.from_agents(
190
+ result.agents,
191
+ max_price=0.10,
192
+ )
193
+ ```
194
+
195
+ ## API reference
196
+
197
+ ### `PactSpecToolkit`
198
+
199
+ | Method | Description |
200
+ |--------|-------------|
201
+ | `from_registry(query, ...)` | Search the registry and create tools from matching agents |
202
+ | `from_agent(spec_id, ...)` | Create tools from a specific agent by spec ID or UUID |
203
+ | `from_agents(agents, ...)` | Create tools from pre-fetched `AgentRecord` objects |
204
+ | `get_tools()` | Return all tools (standard LangChain toolkit interface) |
205
+ | `get_tool(name)` | Look up a single tool by name |
206
+ | `tool_names` | List of all tool names |
207
+
208
+ ### `PactSpecTool`
209
+
210
+ Extends `langchain_core.tools.BaseTool`. Each instance wraps a single PactSpec agent skill.
211
+
212
+ | Attribute | Description |
213
+ |-----------|-------------|
214
+ | `agent_meta` | `AgentMetadata` with agent ID, endpoint, verified status |
215
+ | `skill_meta` | `SkillMetadata` with skill ID, description, pricing, input schema |
216
+ | `timeout` | HTTP timeout in seconds (default: 30) |
217
+ | `auth_headers` | Extra headers sent with every invocation |
218
+
219
+ ## License
220
+
221
+ MIT
@@ -0,0 +1,195 @@
1
+ # pactspec-langchain
2
+
3
+ LangChain integration for [PactSpec](https://pactspec.dev) -- discover and invoke verified AI agents as LangChain tools with automatic pricing awareness.
4
+
5
+ Each skill in a PactSpec agent becomes a LangChain `BaseTool` with its name, description, input schema, and pricing information derived from the spec. The LLM sees pricing in the tool description and can make cost-aware decisions.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install pactspec-langchain
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from pactspec_langchain import PactSpecToolkit
17
+
18
+ # Discover verified agents for a task
19
+ toolkit = PactSpecToolkit.from_registry(
20
+ query="invoice processing",
21
+ verified_only=True,
22
+ max_price=0.10,
23
+ )
24
+
25
+ # List what was found
26
+ for tool in toolkit.get_tools():
27
+ print(f"{tool.name}: {tool.description}")
28
+ ```
29
+
30
+ ## Using with a LangChain agent
31
+
32
+ ```python
33
+ from langchain_openai import ChatOpenAI
34
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
35
+ from langchain.agents import create_tool_calling_agent, AgentExecutor
36
+ from pactspec_langchain import PactSpecToolkit
37
+
38
+ # 1. Discover tools from the registry
39
+ toolkit = PactSpecToolkit.from_registry(
40
+ query="document analysis",
41
+ verified_only=True,
42
+ max_price=0.25,
43
+ )
44
+
45
+ # 2. Set up the LLM and prompt
46
+ llm = ChatOpenAI(model="gpt-4o")
47
+ prompt = ChatPromptTemplate.from_messages([
48
+ ("system", "You are a helpful assistant. Use the available tools to help the user. "
49
+ "Pay attention to tool costs and prefer cheaper options when quality is similar."),
50
+ ("human", "{input}"),
51
+ MessagesPlaceholder("agent_scratchpad"),
52
+ ])
53
+
54
+ # 3. Create the agent
55
+ agent = create_tool_calling_agent(llm, toolkit.get_tools(), prompt)
56
+ executor = AgentExecutor(agent=agent, tools=toolkit.get_tools(), verbose=True)
57
+
58
+ # 4. Run it
59
+ result = executor.invoke({"input": "Extract line items from this invoice: ..."})
60
+ print(result["output"])
61
+ ```
62
+
63
+ ## Load a specific agent
64
+
65
+ If you know the exact agent you want, load it by spec ID:
66
+
67
+ ```python
68
+ toolkit = PactSpecToolkit.from_agent(
69
+ "urn:pactspec:acme/invoice-agent@1.0.0",
70
+ auth_headers={"Authorization": "Bearer sk-your-api-key"},
71
+ )
72
+ ```
73
+
74
+ ## Filtering
75
+
76
+ ### By price
77
+
78
+ ```python
79
+ # Only tools that cost at most $0.05 per invocation
80
+ toolkit = PactSpecToolkit.from_registry(
81
+ query="translation",
82
+ max_price=0.05,
83
+ )
84
+ ```
85
+
86
+ ### By pricing model
87
+
88
+ ```python
89
+ # Only free tools
90
+ toolkit = PactSpecToolkit.from_registry(
91
+ query="summarization",
92
+ pricing_model="free",
93
+ )
94
+
95
+ # Only per-token pricing
96
+ toolkit = PactSpecToolkit.from_registry(
97
+ query="summarization",
98
+ pricing_model="per-token",
99
+ )
100
+ ```
101
+
102
+ ### Verified agents only
103
+
104
+ ```python
105
+ # Only agents that have passed PactSpec verification
106
+ toolkit = PactSpecToolkit.from_registry(
107
+ query="code review",
108
+ verified_only=True,
109
+ )
110
+ ```
111
+
112
+ ### Combining filters
113
+
114
+ ```python
115
+ toolkit = PactSpecToolkit.from_registry(
116
+ query="medical coding",
117
+ verified_only=True,
118
+ max_price=0.10,
119
+ pricing_model="per-invocation",
120
+ )
121
+ ```
122
+
123
+ ## How pricing appears in tool descriptions
124
+
125
+ Each tool's description includes pricing and verification status so the LLM can reason about costs:
126
+
127
+ ```
128
+ Extract line items from invoices | Cost: 0.05 USD/per-invocation via stripe | [Verified] | Agent: InvoiceBot v2.1.0
129
+ ```
130
+
131
+ For free tools:
132
+
133
+ ```
134
+ Summarize text documents | Cost: Free | Agent: SummaryAgent v1.0.0
135
+ ```
136
+
137
+ ## Authentication
138
+
139
+ Pass headers for authenticated agent endpoints:
140
+
141
+ ```python
142
+ toolkit = PactSpecToolkit.from_registry(
143
+ query="invoice processing",
144
+ auth_headers={
145
+ "Authorization": "Bearer sk-your-key",
146
+ "X-API-Key": "your-api-key",
147
+ },
148
+ )
149
+ ```
150
+
151
+ ## Using pre-fetched agents
152
+
153
+ If you've already fetched agents via the PactSpec Python SDK, avoid a second network call:
154
+
155
+ ```python
156
+ from pactspec import PactSpecClient
157
+ from pactspec_langchain import PactSpecToolkit
158
+
159
+ client = PactSpecClient()
160
+ result = client.search(q="translation", verified_only=True)
161
+
162
+ # Pass agent records directly
163
+ toolkit = PactSpecToolkit.from_agents(
164
+ result.agents,
165
+ max_price=0.10,
166
+ )
167
+ ```
168
+
169
+ ## API reference
170
+
171
+ ### `PactSpecToolkit`
172
+
173
+ | Method | Description |
174
+ |--------|-------------|
175
+ | `from_registry(query, ...)` | Search the registry and create tools from matching agents |
176
+ | `from_agent(spec_id, ...)` | Create tools from a specific agent by spec ID or UUID |
177
+ | `from_agents(agents, ...)` | Create tools from pre-fetched `AgentRecord` objects |
178
+ | `get_tools()` | Return all tools (standard LangChain toolkit interface) |
179
+ | `get_tool(name)` | Look up a single tool by name |
180
+ | `tool_names` | List of all tool names |
181
+
182
+ ### `PactSpecTool`
183
+
184
+ Extends `langchain_core.tools.BaseTool`. Each instance wraps a single PactSpec agent skill.
185
+
186
+ | Attribute | Description |
187
+ |-----------|-------------|
188
+ | `agent_meta` | `AgentMetadata` with agent ID, endpoint, verified status |
189
+ | `skill_meta` | `SkillMetadata` with skill ID, description, pricing, input schema |
190
+ | `timeout` | HTTP timeout in seconds (default: 30) |
191
+ | `auth_headers` | Extra headers sent with every invocation |
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,27 @@
1
+ """PactSpec LangChain integration — use PactSpec agents as LangChain tools.
2
+
3
+ Quick start::
4
+
5
+ from pactspec_langchain import PactSpecToolkit
6
+
7
+ toolkit = PactSpecToolkit.from_registry(
8
+ query="invoice processing",
9
+ verified_only=True,
10
+ max_price=0.10,
11
+ )
12
+ tools = toolkit.get_tools()
13
+ """
14
+
15
+ from .toolkit import PactSpecToolkit
16
+ from .tools import PactSpecTool
17
+ from .types import AgentMetadata, SkillMetadata, SkillPricing
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "PactSpecToolkit",
23
+ "PactSpecTool",
24
+ "AgentMetadata",
25
+ "SkillMetadata",
26
+ "SkillPricing",
27
+ ]
@@ -0,0 +1,272 @@
1
+ """PactSpec LangChain toolkit — discover agents from the registry and use them as tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional, Sequence
7
+
8
+ from pactspec import PactSpecClient, AgentRecord
9
+
10
+ from .tools import PactSpecTool
11
+ from .types import AgentMetadata, SkillMetadata, SkillPricing
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _extract_tools_from_agent(
17
+ agent: AgentRecord,
18
+ *,
19
+ max_price: Optional[float] = None,
20
+ pricing_model: Optional[str] = None,
21
+ auth_headers: Optional[Dict[str, str]] = None,
22
+ timeout: float = 30.0,
23
+ ) -> List[PactSpecTool]:
24
+ """Extract PactSpecTool instances from a single AgentRecord.
25
+
26
+ Applies pricing / model filters at the skill level.
27
+ """
28
+ spec = agent.spec or {}
29
+ skills: List[Dict[str, Any]] = spec.get("skills", [])
30
+
31
+ agent_meta = AgentMetadata(
32
+ agent_id=agent.id,
33
+ spec_id=agent.spec_id,
34
+ name=agent.name,
35
+ version=agent.version,
36
+ endpoint_url=agent.endpoint_url,
37
+ verified=agent.verified,
38
+ attestation_hash=agent.attestation_hash,
39
+ provider_name=agent.provider_name,
40
+ )
41
+
42
+ tools: List[PactSpecTool] = []
43
+ for skill in skills:
44
+ pricing_raw = skill.get("pricing", {})
45
+ pricing = SkillPricing.from_dict(pricing_raw)
46
+
47
+ # --- filters ---
48
+ if pricing_model is not None and pricing.model != pricing_model:
49
+ continue
50
+
51
+ if max_price is not None and pricing.model != "free":
52
+ if pricing.amount > max_price:
53
+ logger.debug(
54
+ "Skipping skill %s: price %.4f > max_price %.4f",
55
+ skill.get("id"),
56
+ pricing.amount,
57
+ max_price,
58
+ )
59
+ continue
60
+
61
+ skill_meta = SkillMetadata(
62
+ skill_id=skill.get("id", skill.get("name", "unknown")),
63
+ skill_name=skill.get("name", skill.get("id", "")),
64
+ description=skill.get("description", ""),
65
+ input_schema=skill.get("inputSchema", {}),
66
+ output_schema=skill.get("outputSchema", {}),
67
+ pricing=pricing,
68
+ tags=skill.get("tags", []),
69
+ examples=skill.get("examples", []),
70
+ )
71
+
72
+ tool = PactSpecTool(
73
+ agent_meta=agent_meta,
74
+ skill_meta=skill_meta,
75
+ timeout=timeout,
76
+ auth_headers=auth_headers or {},
77
+ )
78
+ tools.append(tool)
79
+
80
+ return tools
81
+
82
+
83
+ class PactSpecToolkit:
84
+ """Discover PactSpec agents from the registry and expose them as LangChain tools.
85
+
86
+ Usage::
87
+
88
+ from pactspec_langchain import PactSpecToolkit
89
+
90
+ toolkit = PactSpecToolkit.from_registry(
91
+ query="invoice processing",
92
+ verified_only=True,
93
+ max_price=0.10,
94
+ )
95
+ tools = toolkit.get_tools()
96
+
97
+ Each skill in each matched agent becomes a separate :class:`PactSpecTool`.
98
+ Pricing information is embedded in the tool description so the LLM can make
99
+ cost-aware decisions.
100
+ """
101
+
102
+ def __init__(self, tools: List[PactSpecTool]) -> None:
103
+ self._tools = list(tools)
104
+
105
+ # ------------------------------------------------------------------
106
+ # Factory: search the registry
107
+ # ------------------------------------------------------------------
108
+
109
+ @classmethod
110
+ def from_registry(
111
+ cls,
112
+ query: str = "",
113
+ *,
114
+ verified_only: bool = False,
115
+ max_price: Optional[float] = None,
116
+ pricing_model: Optional[str] = None,
117
+ registry: str = "https://pactspec.dev",
118
+ limit: int = 10,
119
+ auth_headers: Optional[Dict[str, str]] = None,
120
+ timeout: float = 30.0,
121
+ ) -> "PactSpecToolkit":
122
+ """Search the PactSpec registry and build tools from matching agents.
123
+
124
+ Args:
125
+ query: Free-text search query (e.g. ``"invoice processing"``).
126
+ verified_only: Only include agents that have passed verification.
127
+ max_price: Maximum price per invocation — skills above this are excluded.
128
+ pricing_model: Only include skills with this pricing model
129
+ (``"free"``, ``"per-invocation"``, ``"per-token"``, ``"per-second"``).
130
+ registry: PactSpec registry URL.
131
+ limit: Maximum number of agents to fetch.
132
+ auth_headers: Extra HTTP headers to send when invoking agent endpoints
133
+ (e.g. ``{"Authorization": "Bearer sk-..."}``).
134
+ timeout: HTTP timeout in seconds for agent invocations.
135
+
136
+ Returns:
137
+ A :class:`PactSpecToolkit` containing one tool per qualifying skill.
138
+ """
139
+ client = PactSpecClient(registry=registry)
140
+ result = client.search(q=query, verified_only=verified_only, limit=limit)
141
+
142
+ tools: List[PactSpecTool] = []
143
+ for agent in result.agents:
144
+ tools.extend(
145
+ _extract_tools_from_agent(
146
+ agent,
147
+ max_price=max_price,
148
+ pricing_model=pricing_model,
149
+ auth_headers=auth_headers,
150
+ timeout=timeout,
151
+ )
152
+ )
153
+
154
+ logger.info(
155
+ "PactSpecToolkit.from_registry: query=%r, found %d agents, %d tools",
156
+ query,
157
+ len(result.agents),
158
+ len(tools),
159
+ )
160
+ return cls(tools)
161
+
162
+ # ------------------------------------------------------------------
163
+ # Factory: from a specific agent
164
+ # ------------------------------------------------------------------
165
+
166
+ @classmethod
167
+ def from_agent(
168
+ cls,
169
+ spec_id: str,
170
+ *,
171
+ max_price: Optional[float] = None,
172
+ pricing_model: Optional[str] = None,
173
+ registry: str = "https://pactspec.dev",
174
+ auth_headers: Optional[Dict[str, str]] = None,
175
+ timeout: float = 30.0,
176
+ ) -> "PactSpecToolkit":
177
+ """Create tools from a specific agent by its spec ID or registry UUID.
178
+
179
+ Args:
180
+ spec_id: The agent's spec URN (e.g. ``"urn:pactspec:acme/invoice-agent@1.0.0"``)
181
+ or registry UUID.
182
+ max_price: Maximum price per invocation.
183
+ pricing_model: Only include skills with this pricing model.
184
+ registry: PactSpec registry URL.
185
+ auth_headers: Extra HTTP headers for agent invocations.
186
+ timeout: HTTP timeout in seconds for agent invocations.
187
+
188
+ Returns:
189
+ A :class:`PactSpecToolkit` containing one tool per qualifying skill.
190
+ """
191
+ client = PactSpecClient(registry=registry)
192
+ agent = client.get_agent(spec_id)
193
+
194
+ tools = _extract_tools_from_agent(
195
+ agent,
196
+ max_price=max_price,
197
+ pricing_model=pricing_model,
198
+ auth_headers=auth_headers,
199
+ timeout=timeout,
200
+ )
201
+
202
+ logger.info(
203
+ "PactSpecToolkit.from_agent: spec_id=%r, %d tools",
204
+ spec_id,
205
+ len(tools),
206
+ )
207
+ return cls(tools)
208
+
209
+ # ------------------------------------------------------------------
210
+ # Factory: from raw agent dicts (for advanced use)
211
+ # ------------------------------------------------------------------
212
+
213
+ @classmethod
214
+ def from_agents(
215
+ cls,
216
+ agents: Sequence[AgentRecord],
217
+ *,
218
+ max_price: Optional[float] = None,
219
+ pricing_model: Optional[str] = None,
220
+ auth_headers: Optional[Dict[str, str]] = None,
221
+ timeout: float = 30.0,
222
+ ) -> "PactSpecToolkit":
223
+ """Create a toolkit from a pre-fetched list of :class:`AgentRecord` objects.
224
+
225
+ This is useful when you've already fetched agents and want to avoid
226
+ a second network call.
227
+ """
228
+ tools: List[PactSpecTool] = []
229
+ for agent in agents:
230
+ tools.extend(
231
+ _extract_tools_from_agent(
232
+ agent,
233
+ max_price=max_price,
234
+ pricing_model=pricing_model,
235
+ auth_headers=auth_headers,
236
+ timeout=timeout,
237
+ )
238
+ )
239
+ return cls(tools)
240
+
241
+ # ------------------------------------------------------------------
242
+ # Tool access
243
+ # ------------------------------------------------------------------
244
+
245
+ def get_tools(self) -> List[PactSpecTool]:
246
+ """Return all tools in the toolkit.
247
+
248
+ This is the standard LangChain toolkit interface used by
249
+ ``create_tool_calling_agent`` and similar helpers.
250
+ """
251
+ return list(self._tools)
252
+
253
+ def get_tool(self, name: str) -> Optional[PactSpecTool]:
254
+ """Look up a single tool by name.
255
+
256
+ Returns ``None`` if no tool with that name exists.
257
+ """
258
+ for tool in self._tools:
259
+ if tool.name == name:
260
+ return tool
261
+ return None
262
+
263
+ @property
264
+ def tool_names(self) -> List[str]:
265
+ """List the names of all tools in the toolkit."""
266
+ return [t.name for t in self._tools]
267
+
268
+ def __len__(self) -> int:
269
+ return len(self._tools)
270
+
271
+ def __repr__(self) -> str:
272
+ return f"PactSpecToolkit(tools={self.tool_names})"
@@ -0,0 +1,193 @@
1
+ """PactSpec LangChain tool — wraps a single PactSpec agent skill as a LangChain BaseTool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Dict, Optional, Type
8
+
9
+ import httpx
10
+ from langchain_core.callbacks import (
11
+ AsyncCallbackManagerForToolRun,
12
+ CallbackManagerForToolRun,
13
+ )
14
+ from langchain_core.tools import BaseTool
15
+ from pydantic import BaseModel, Field, model_validator
16
+
17
+ from .types import AgentMetadata, SkillMetadata, SkillPricing
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _build_pydantic_model_from_json_schema(
23
+ schema: Dict[str, Any],
24
+ model_name: str = "DynamicInput",
25
+ ) -> Type[BaseModel]:
26
+ """Build a pydantic model from a JSON Schema dict.
27
+
28
+ This creates a model with fields derived from the JSON Schema ``properties``.
29
+ For properties without an explicit type we fall back to ``Any``.
30
+ """
31
+ properties = schema.get("properties", {})
32
+ required = set(schema.get("required", []))
33
+
34
+ field_definitions: Dict[str, Any] = {}
35
+ annotations: Dict[str, Any] = {}
36
+
37
+ type_map = {
38
+ "string": str,
39
+ "integer": int,
40
+ "number": float,
41
+ "boolean": bool,
42
+ "array": list,
43
+ "object": dict,
44
+ }
45
+
46
+ for prop_name, prop_schema in properties.items():
47
+ json_type = prop_schema.get("type", "string")
48
+ python_type = type_map.get(json_type, Any)
49
+ description = prop_schema.get("description", "")
50
+ default = prop_schema.get("default")
51
+
52
+ if prop_name in required and default is None:
53
+ field_definitions[prop_name] = Field(description=description)
54
+ else:
55
+ if python_type is Any:
56
+ annotations[prop_name] = Optional[Any]
57
+ else:
58
+ annotations[prop_name] = Optional[python_type] # type: ignore[assignment]
59
+ field_definitions[prop_name] = Field(default=default, description=description)
60
+
61
+ if prop_name not in annotations:
62
+ annotations[prop_name] = python_type
63
+
64
+ namespace: Dict[str, Any] = {"__annotations__": annotations, **field_definitions}
65
+ model = type(model_name, (BaseModel,), namespace)
66
+ return model # type: ignore[return-value]
67
+
68
+
69
+ class PactSpecTool(BaseTool):
70
+ """A LangChain tool backed by a single PactSpec agent skill.
71
+
72
+ Each skill declared in a PactSpec becomes one ``PactSpecTool`` instance.
73
+ The tool name, description, and input schema are derived from the spec,
74
+ and the ``_run`` / ``_arun`` methods invoke the agent endpoint directly.
75
+
76
+ Pricing information is appended to the tool description so that the LLM
77
+ can make cost-aware decisions when selecting tools.
78
+ """
79
+
80
+ # --- metadata stored on the tool ---
81
+ agent_meta: AgentMetadata
82
+ skill_meta: SkillMetadata
83
+
84
+ # --- httpx settings ---
85
+ timeout: float = 30.0
86
+ auth_headers: Dict[str, str] = Field(default_factory=dict)
87
+
88
+ # These are set via model_validator from the metadata
89
+ name: str = "" # type: ignore[assignment]
90
+ description: str = ""
91
+
92
+ args_schema: Optional[Type[BaseModel]] = None # type: ignore[assignment]
93
+
94
+ @model_validator(mode="after")
95
+ def _set_derived_fields(self) -> "PactSpecTool":
96
+ # Tool name: use skill_id, sanitised for LangChain (alphanumeric + hyphens/underscores)
97
+ if not self.name:
98
+ self.name = self.skill_meta.skill_id.replace(" ", "_")
99
+
100
+ # Build a rich description with pricing and verification status
101
+ if not self.description:
102
+ parts = [self.skill_meta.description or self.skill_meta.skill_name]
103
+ pricing_display = self.skill_meta.pricing.display
104
+ if pricing_display:
105
+ parts.append(f"Cost: {pricing_display}")
106
+ if self.agent_meta.verified:
107
+ parts.append("[Verified]")
108
+ parts.append(f"Agent: {self.agent_meta.name} v{self.agent_meta.version}")
109
+ self.description = " | ".join(parts)
110
+
111
+ # Build args_schema from the skill's inputSchema
112
+ if self.args_schema is None and self.skill_meta.input_schema:
113
+ model_name = (
114
+ self.skill_meta.skill_id.replace("-", "_").replace(" ", "_").title().replace("_", "")
115
+ + "Input"
116
+ )
117
+ self.args_schema = _build_pydantic_model_from_json_schema(
118
+ self.skill_meta.input_schema,
119
+ model_name=model_name,
120
+ )
121
+
122
+ return self
123
+
124
+ # ------------------------------------------------------------------
125
+ # Invocation
126
+ # ------------------------------------------------------------------
127
+
128
+ def _build_url(self) -> str:
129
+ """Construct the invocation URL for the agent skill."""
130
+ base = self.agent_meta.endpoint_url.rstrip("/")
131
+ return base
132
+
133
+ def _build_headers(self) -> Dict[str, str]:
134
+ headers = {"Content-Type": "application/json"}
135
+ headers.update(self.auth_headers)
136
+ return headers
137
+
138
+ def _handle_response(self, response: httpx.Response) -> str:
139
+ """Process the HTTP response and return a string for the LLM."""
140
+ if response.status_code == 402:
141
+ try:
142
+ detail = response.json()
143
+ except Exception:
144
+ detail = response.text
145
+ return f"Payment required for this agent skill. Details: {detail}"
146
+
147
+ response.raise_for_status()
148
+
149
+ # Try to return formatted JSON; fall back to raw text
150
+ content_type = response.headers.get("content-type", "")
151
+ if "json" in content_type:
152
+ try:
153
+ return json.dumps(response.json(), indent=2)
154
+ except Exception:
155
+ pass
156
+ return response.text
157
+
158
+ def _run(
159
+ self,
160
+ run_manager: Optional[CallbackManagerForToolRun] = None,
161
+ **kwargs: Any,
162
+ ) -> str:
163
+ """Synchronously invoke the PactSpec agent skill."""
164
+ url = self._build_url()
165
+ headers = self._build_headers()
166
+ logger.debug("PactSpecTool %s: POST %s with %s", self.name, url, kwargs)
167
+
168
+ response = httpx.post(
169
+ url,
170
+ json=kwargs,
171
+ headers=headers,
172
+ timeout=self.timeout,
173
+ )
174
+ return self._handle_response(response)
175
+
176
+ async def _arun(
177
+ self,
178
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
179
+ **kwargs: Any,
180
+ ) -> str:
181
+ """Asynchronously invoke the PactSpec agent skill."""
182
+ url = self._build_url()
183
+ headers = self._build_headers()
184
+ logger.debug("PactSpecTool %s: async POST %s with %s", self.name, url, kwargs)
185
+
186
+ async with httpx.AsyncClient() as client:
187
+ response = await client.post(
188
+ url,
189
+ json=kwargs,
190
+ headers=headers,
191
+ timeout=self.timeout,
192
+ )
193
+ return self._handle_response(response)
@@ -0,0 +1,64 @@
1
+ """Type definitions for the PactSpec LangChain integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class SkillPricing(BaseModel):
11
+ """Pricing information extracted from a PactSpec skill."""
12
+
13
+ model: str = "free"
14
+ amount: float = 0.0
15
+ currency: str = "USD"
16
+ protocol: Optional[str] = None
17
+
18
+ @property
19
+ def display(self) -> str:
20
+ """Human-readable pricing string for tool descriptions."""
21
+ if self.model == "free":
22
+ return "Free"
23
+ parts = [f"{self.amount} {self.currency}/{self.model}"]
24
+ if self.protocol and self.protocol != "none":
25
+ parts.append(f"via {self.protocol}")
26
+ return " ".join(parts)
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: Dict[str, Any]) -> "SkillPricing":
30
+ """Parse pricing from a raw PactSpec skill pricing dict."""
31
+ if not data:
32
+ return cls()
33
+ return cls(
34
+ model=data.get("model", "free"),
35
+ amount=data.get("amount", 0.0),
36
+ currency=data.get("currency", "USD"),
37
+ protocol=data.get("protocol"),
38
+ )
39
+
40
+
41
+ class SkillMetadata(BaseModel):
42
+ """Metadata about a PactSpec skill used to construct a LangChain tool."""
43
+
44
+ skill_id: str
45
+ skill_name: str
46
+ description: str
47
+ input_schema: Dict[str, Any] = Field(default_factory=dict)
48
+ output_schema: Dict[str, Any] = Field(default_factory=dict)
49
+ pricing: SkillPricing = Field(default_factory=SkillPricing)
50
+ tags: List[str] = Field(default_factory=list)
51
+ examples: List[Dict[str, Any]] = Field(default_factory=list)
52
+
53
+
54
+ class AgentMetadata(BaseModel):
55
+ """Metadata about the parent PactSpec agent for a tool."""
56
+
57
+ agent_id: str
58
+ spec_id: str
59
+ name: str
60
+ version: str
61
+ endpoint_url: str
62
+ verified: bool = False
63
+ attestation_hash: Optional[str] = None
64
+ provider_name: str = ""
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pactspec-langchain"
7
+ version = "0.1.0"
8
+ description = "LangChain integration for PactSpec — discover and invoke verified AI agents as LangChain tools"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "PactSpec", email = "hello@pactspec.dev" },
14
+ ]
15
+ keywords = ["pactspec", "langchain", "agents", "tools", "ai"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+ dependencies = [
29
+ "pactspec>=0.1.0",
30
+ "langchain-core>=0.3.0",
31
+ "httpx>=0.25.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://pactspec.dev"
36
+ Repository = "https://github.com/pactspec/pactspec"
37
+ Documentation = "https://pactspec.dev/docs/integrations/langchain"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["pactspec_langchain"]