spineforge 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.
- spineforge-0.1.0/.gitignore +23 -0
- spineforge-0.1.0/LICENSE +7 -0
- spineforge-0.1.0/PKG-INFO +277 -0
- spineforge-0.1.0/README.md +232 -0
- spineforge-0.1.0/docs/scope_model.md +124 -0
- spineforge-0.1.0/examples/agent_a_caller.py +157 -0
- spineforge-0.1.0/examples/agent_b.log +0 -0
- spineforge-0.1.0/examples/agent_b_receiver.py +149 -0
- spineforge-0.1.0/examples/cake_recipe.txt +25 -0
- spineforge-0.1.0/examples/langchain_agent_demo.py +106 -0
- spineforge-0.1.0/examples/research_output.txt +50 -0
- spineforge-0.1.0/examples/rubiks_cube_solution.txt +33 -0
- spineforge-0.1.0/examples/run_multi_agent_demo.sh +62 -0
- spineforge-0.1.0/examples/scratch_agent_demo.py +151 -0
- spineforge-0.1.0/examples/tools.py +30 -0
- spineforge-0.1.0/pyproject.toml +56 -0
- spineforge-0.1.0/spineforge/__init__.py +422 -0
- spineforge-0.1.0/spineforge/config.py +122 -0
- spineforge-0.1.0/spineforge/decorators.py +103 -0
- spineforge-0.1.0/spineforge/identity.py +249 -0
- spineforge-0.1.0/spineforge/instrumentation.py +218 -0
- spineforge-0.1.0/spineforge/models.py +95 -0
- spineforge-0.1.0/spineforge/registry_client.py +345 -0
- spineforge-0.1.0/spineforge/sinks.py +572 -0
- spineforge-0.1.0/spineforge/span_processor.py +402 -0
- spineforge-0.1.0/spineforge/verify.py +222 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Runtime data (created by spineforge at runtime)
|
|
2
|
+
.spineforge/
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg
|
|
12
|
+
|
|
13
|
+
# Environment
|
|
14
|
+
.env
|
|
15
|
+
.venv/
|
|
16
|
+
env/
|
|
17
|
+
venv/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
spineforge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Spineforge. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are proprietary and confidential.
|
|
4
|
+
Unauthorized copying, distribution, reproduction, publication, or modification of this file,
|
|
5
|
+
via any medium, is strictly prohibited.
|
|
6
|
+
|
|
7
|
+
This software is for internal company use only, unless explicitly authorized in writing by Spineforge.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spineforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI agent identity and observability SDK — Okta + Datadog for the AI agent era
|
|
5
|
+
Project-URL: Homepage, https://spineforge-site.vercel.app/
|
|
6
|
+
Project-URL: Documentation, https://spineforge-site.vercel.app/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/spineforge/spineforge
|
|
8
|
+
Author-email: Kushagra Chavel <kushagrachavel@gmail.com>
|
|
9
|
+
License: Copyright (c) 2026 Spineforge. All rights reserved.
|
|
10
|
+
|
|
11
|
+
This software and associated documentation files (the "Software") are proprietary and confidential.
|
|
12
|
+
Unauthorized copying, distribution, reproduction, publication, or modification of this file,
|
|
13
|
+
via any medium, is strictly prohibited.
|
|
14
|
+
|
|
15
|
+
This software is for internal company use only, unless explicitly authorized in writing by Spineforge.
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Classifier: Development Status :: 3 - Alpha
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: Other/Proprietary License
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: System :: Monitoring
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: cryptography>=43.0.0
|
|
28
|
+
Requires-Dist: opentelemetry-api>=1.20.0
|
|
29
|
+
Requires-Dist: opentelemetry-instrumentation-groq>=0.35.0
|
|
30
|
+
Requires-Dist: opentelemetry-instrumentation-langchain>=0.35.0
|
|
31
|
+
Requires-Dist: opentelemetry-sdk>=1.20.0
|
|
32
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.41b0
|
|
33
|
+
Requires-Dist: pyjwt>=2.9.0
|
|
34
|
+
Provides-Extra: examples
|
|
35
|
+
Requires-Dist: duckduckgo-search; extra == 'examples'
|
|
36
|
+
Requires-Dist: groq; extra == 'examples'
|
|
37
|
+
Requires-Dist: langchain; extra == 'examples'
|
|
38
|
+
Requires-Dist: langchain-community; extra == 'examples'
|
|
39
|
+
Requires-Dist: langchain-core; extra == 'examples'
|
|
40
|
+
Requires-Dist: langchain-groq; extra == 'examples'
|
|
41
|
+
Requires-Dist: pydantic; extra == 'examples'
|
|
42
|
+
Requires-Dist: python-dotenv; extra == 'examples'
|
|
43
|
+
Requires-Dist: wikipedia; extra == 'examples'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# Spineforge SDK
|
|
47
|
+
|
|
48
|
+
> AI agent identity and observability — **Okta + Datadog for the AI agent era.**
|
|
49
|
+
|
|
50
|
+
[Homepage](https://spineforge-site.vercel.app/) | [Documentation](https://spineforge-site.vercel.app/docs)
|
|
51
|
+
|
|
52
|
+
Spineforge assigns every AI agent a stable identity (Spine ID), instruments LLM and tool calls via [OpenLLMetry](https://github.com/traceloop/openllmetry) (OpenTelemetry-based auto-instrumentation), and groups events into Runs and Actions that map directly to the hosted Spineforge backend.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
### Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install spineforge
|
|
62
|
+
|
|
63
|
+
# For running the example agents
|
|
64
|
+
pip install "spineforge[examples]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Usage (3 lines to integrate)
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import spineforge
|
|
71
|
+
|
|
72
|
+
# 1. Init — resolves/creates a stable Spine ID for this agent
|
|
73
|
+
spine = spineforge.init(agent_name="my-research-agent")
|
|
74
|
+
|
|
75
|
+
# 2. Wrap your agent's top-level invocation in a run
|
|
76
|
+
with spine.run(input=user_query) as run:
|
|
77
|
+
result = run_agent(user_query)
|
|
78
|
+
run.set_output(result)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That's it. Spineforge auto-instruments LLM calls (Groq, LangChain) in the background via OpenTelemetry. No code changes to your agent logic.
|
|
82
|
+
|
|
83
|
+
### For tool functions not auto-captured
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
@spineforge.track_tool
|
|
87
|
+
def call_tool(name: str, query: str) -> str:
|
|
88
|
+
"""Only needed for raw SDK agents — LangChain tools are auto-captured."""
|
|
89
|
+
return tools[name].run(query)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## How It Works
|
|
95
|
+
|
|
96
|
+
### `init(agent_name)` → `Spine`
|
|
97
|
+
|
|
98
|
+
1. **Resolves identity** — looks up `agent_name` in `.spineforge/registry.json`. If it exists, returns the same Spine ID. If not, generates a new UUID-4 and persists it.
|
|
99
|
+
2. **Wires instrumentation** — creates an OpenTelemetry `TracerProvider`, registers a custom `SpineforgeSpanProcessor`, and activates OpenLLMetry instrumentors for installed libraries (Groq, LangChain).
|
|
100
|
+
3. **Returns a `Spine` handle** — use `spine.run()` to group actions into runs.
|
|
101
|
+
|
|
102
|
+
### `spine.run(input=...)` → context manager
|
|
103
|
+
|
|
104
|
+
The `with spine.run(...)` block:
|
|
105
|
+
- Generates a `run_id` and sets it in a `ContextVar` so all spans created during the block are associated with this run.
|
|
106
|
+
- Emits a `RunEvent(status="running")` at entry.
|
|
107
|
+
- Emits a `RunEvent(status="success")` or `RunEvent(status="error")` at exit.
|
|
108
|
+
- Force-flushes the OTel pipeline to ensure all action spans are written before the run-end event.
|
|
109
|
+
|
|
110
|
+
### Span → ActionEvent translation
|
|
111
|
+
|
|
112
|
+
The `SpineforgeSpanProcessor` (a custom OTel `SpanProcessor`) receives every completed span and classifies it:
|
|
113
|
+
|
|
114
|
+
| Source | Span attribute | ActionEvent type |
|
|
115
|
+
|--------|---------------|------------------|
|
|
116
|
+
| Groq instrumentor | `gen_ai.system` present | `llm_call` |
|
|
117
|
+
| LangChain instrumentor | `traceloop.span.kind = "llm"` | `llm_call` |
|
|
118
|
+
| LangChain instrumentor | `traceloop.span.kind = "tool"` | `tool_call` |
|
|
119
|
+
| `@track_tool` decorator | `spineforge.tool_call = True` | `tool_call` |
|
|
120
|
+
|
|
121
|
+
Unrecognised spans (HTTP, framework internals) are silently ignored.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Sink Abstraction
|
|
126
|
+
|
|
127
|
+
All events flow through a `Sink` interface:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
class Sink(ABC):
|
|
131
|
+
def emit(self, event: dict) -> None: ...
|
|
132
|
+
def flush(self) -> None: ...
|
|
133
|
+
def shutdown(self) -> None: ...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Built-in sinks:**
|
|
137
|
+
- `ConsoleSink` — compact, coloured one-liners to stdout
|
|
138
|
+
- `FileSink` — non-blocking JSONL append via background thread + queue
|
|
139
|
+
- `APISink` — batched, non-blocking POSTs to the Spineforge backend with automatic retry and local file fallback
|
|
140
|
+
|
|
141
|
+
**Adding a custom sink (e.g. `SupabaseSink`):**
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from spineforge.sinks import Sink
|
|
145
|
+
|
|
146
|
+
class SupabaseSink(Sink):
|
|
147
|
+
def __init__(self, supabase_url: str, api_key: str):
|
|
148
|
+
self._client = create_supabase_client(supabase_url, api_key)
|
|
149
|
+
|
|
150
|
+
def emit(self, event: dict) -> None:
|
|
151
|
+
table = "runs" if event["event"] == "run" else "actions"
|
|
152
|
+
self._client.table(table).insert(event).execute()
|
|
153
|
+
|
|
154
|
+
def flush(self) -> None:
|
|
155
|
+
pass # or batch-flush pending writes
|
|
156
|
+
|
|
157
|
+
def shutdown(self) -> None:
|
|
158
|
+
self._client.close()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
No changes to `instrumentation.py` or `span_processor.py` needed — just add the sink to the list in `__init__.py:init()`.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Adding a New Framework Instrumentor
|
|
166
|
+
|
|
167
|
+
To add support for a new framework (e.g. CrewAI):
|
|
168
|
+
|
|
169
|
+
### 1. Install the instrumentor package
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install opentelemetry-instrumentation-crewai
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 2. Add activation in `instrumentation.py`
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# In _instrument_libraries():
|
|
179
|
+
try:
|
|
180
|
+
from opentelemetry.instrumentation.crewai import CrewAIInstrumentor
|
|
181
|
+
CrewAIInstrumentor().instrument(tracer_provider=provider)
|
|
182
|
+
_log_instrumentor_status("CrewAI", True)
|
|
183
|
+
except ImportError:
|
|
184
|
+
_log_instrumentor_status("CrewAI", False, "not installed")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 3. Add span classification in `span_processor.py`
|
|
188
|
+
|
|
189
|
+
Add a new condition in `_classify_and_build()` to detect CrewAI-specific span attributes and route them to `_build_llm_event()` or a new tool event builder.
|
|
190
|
+
|
|
191
|
+
### 4. Add the dependency to `pyproject.toml`
|
|
192
|
+
|
|
193
|
+
```toml
|
|
194
|
+
"opentelemetry-instrumentation-crewai>=0.35.0",
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Which Example Agents Need `@track_tool`?
|
|
200
|
+
|
|
201
|
+
| Agent | File | `@track_tool` needed? | Why |
|
|
202
|
+
|-------|------|-----------------------|-----|
|
|
203
|
+
| Scratch (raw Groq) | `scratch_agent_demo.py` | **Yes** | The Groq instrumentor only wraps `client.chat.completions.create()` — it has no knowledge of tool execution. `call_tool()` must be decorated. |
|
|
204
|
+
| LangChain | `langchain_agent_demo.py` | **No** | The LangChain instrumentor hooks into LangChain's callback system (`on_tool_start`/`on_tool_end`), which fires automatically when `Tool.run()` is called via `AgentExecutor`. |
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Output Formats
|
|
209
|
+
|
|
210
|
+
### Console (human-readable)
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
🦴 Spineforge initialised: agent='research-agent-scratch' spine_id=a1b2c3d4…
|
|
214
|
+
🦴 [SPINE:a1b2c3d4] RUN started input="What is quantum computing?"
|
|
215
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] ⚡ llm_call llama-3.1-8b-instant 342ms ✓ (prompt:128 comp:256)
|
|
216
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] 🔧 tool_call search_tool 1204ms ✓
|
|
217
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] ⚡ llm_call llama-3.1-8b-instant 567ms ✓ (prompt:384 comp:512)
|
|
218
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] RUN completed 4521ms ✓
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### JSONL (`.spineforge/logs/actions.jsonl`)
|
|
222
|
+
|
|
223
|
+
Each line is a JSON object — either a `run` event or an `action` event:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{"event": "run", "run_id": "...", "spine_id": "...", "agent_name": "...", "status": "running", ...}
|
|
227
|
+
{"event": "action", "action_id": "...", "run_id": "...", "type": "llm_call", "name": "llama-3.1-8b-instant", ...}
|
|
228
|
+
{"event": "action", "action_id": "...", "run_id": "...", "type": "tool_call", "name": "search_tool", ...}
|
|
229
|
+
{"event": "run", "run_id": "...", "status": "success", "ended_at": "...", ...}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Configuration
|
|
235
|
+
|
|
236
|
+
| Env var | Default | Description |
|
|
237
|
+
|---------|---------|-------------|
|
|
238
|
+
| `SPINEFORGE_DATA_DIR` | CWD | Base directory for `.spineforge/` |
|
|
239
|
+
| `SPINEFORGE_LOG_FILE` | `actions.jsonl` | JSONL log filename |
|
|
240
|
+
| `SPINEFORGE_VERBOSE` | `true` | Enable console output |
|
|
241
|
+
| `SPINEFORGE_TRACE_CONTENT` | `true` | Capture prompt/completion text |
|
|
242
|
+
| `SPINEFORGE_REGISTRY_URL` | `""` (empty) | URL of the Spineforge backend. Empty means offline mode. |
|
|
243
|
+
|
|
244
|
+
All can also be passed as kwargs to `spineforge.init()`.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Project Structure
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
spineforge-sdk/
|
|
252
|
+
├── pyproject.toml # Package metadata + dependencies
|
|
253
|
+
├── README.md # This file
|
|
254
|
+
├── spineforge/
|
|
255
|
+
│ ├── __init__.py # Public API: init(), track_tool, Spine, Run
|
|
256
|
+
│ ├── config.py # SpineforgeConfig (env-configurable)
|
|
257
|
+
│ ├── models.py # RunEvent, ActionEvent dataclasses
|
|
258
|
+
│ ├── identity.py # Spine ID registry (registry.json)
|
|
259
|
+
│ ├── sinks.py # Sink ABC + ConsoleSink + FileSink
|
|
260
|
+
│ ├── span_processor.py # SpineforgeSpanProcessor (OTel → events)
|
|
261
|
+
│ ├── instrumentation.py # TracerProvider + instrumentor wiring
|
|
262
|
+
│ └── decorators.py # @track_tool
|
|
263
|
+
├── examples/
|
|
264
|
+
│ ├── scratch_agent_demo.py # scratch.py + spineforge
|
|
265
|
+
│ ├── langchain_agent_demo.py # main.py + spineforge
|
|
266
|
+
│ └── tools.py # Shared tools
|
|
267
|
+
└── .spineforge/ # Created at runtime (gitignored)
|
|
268
|
+
├── registry.json
|
|
269
|
+
└── logs/
|
|
270
|
+
└── actions.jsonl
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
This software is proprietary and confidential. Unauthorized copying, distribution, reproduction, publication, or modification of this software, via any medium, is strictly prohibited. For internal use only.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Spineforge SDK
|
|
2
|
+
|
|
3
|
+
> AI agent identity and observability — **Okta + Datadog for the AI agent era.**
|
|
4
|
+
|
|
5
|
+
[Homepage](https://spineforge-site.vercel.app/) | [Documentation](https://spineforge-site.vercel.app/docs)
|
|
6
|
+
|
|
7
|
+
Spineforge assigns every AI agent a stable identity (Spine ID), instruments LLM and tool calls via [OpenLLMetry](https://github.com/traceloop/openllmetry) (OpenTelemetry-based auto-instrumentation), and groups events into Runs and Actions that map directly to the hosted Spineforge backend.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install spineforge
|
|
17
|
+
|
|
18
|
+
# For running the example agents
|
|
19
|
+
pip install "spineforge[examples]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Usage (3 lines to integrate)
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import spineforge
|
|
26
|
+
|
|
27
|
+
# 1. Init — resolves/creates a stable Spine ID for this agent
|
|
28
|
+
spine = spineforge.init(agent_name="my-research-agent")
|
|
29
|
+
|
|
30
|
+
# 2. Wrap your agent's top-level invocation in a run
|
|
31
|
+
with spine.run(input=user_query) as run:
|
|
32
|
+
result = run_agent(user_query)
|
|
33
|
+
run.set_output(result)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's it. Spineforge auto-instruments LLM calls (Groq, LangChain) in the background via OpenTelemetry. No code changes to your agent logic.
|
|
37
|
+
|
|
38
|
+
### For tool functions not auto-captured
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
@spineforge.track_tool
|
|
42
|
+
def call_tool(name: str, query: str) -> str:
|
|
43
|
+
"""Only needed for raw SDK agents — LangChain tools are auto-captured."""
|
|
44
|
+
return tools[name].run(query)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## How It Works
|
|
50
|
+
|
|
51
|
+
### `init(agent_name)` → `Spine`
|
|
52
|
+
|
|
53
|
+
1. **Resolves identity** — looks up `agent_name` in `.spineforge/registry.json`. If it exists, returns the same Spine ID. If not, generates a new UUID-4 and persists it.
|
|
54
|
+
2. **Wires instrumentation** — creates an OpenTelemetry `TracerProvider`, registers a custom `SpineforgeSpanProcessor`, and activates OpenLLMetry instrumentors for installed libraries (Groq, LangChain).
|
|
55
|
+
3. **Returns a `Spine` handle** — use `spine.run()` to group actions into runs.
|
|
56
|
+
|
|
57
|
+
### `spine.run(input=...)` → context manager
|
|
58
|
+
|
|
59
|
+
The `with spine.run(...)` block:
|
|
60
|
+
- Generates a `run_id` and sets it in a `ContextVar` so all spans created during the block are associated with this run.
|
|
61
|
+
- Emits a `RunEvent(status="running")` at entry.
|
|
62
|
+
- Emits a `RunEvent(status="success")` or `RunEvent(status="error")` at exit.
|
|
63
|
+
- Force-flushes the OTel pipeline to ensure all action spans are written before the run-end event.
|
|
64
|
+
|
|
65
|
+
### Span → ActionEvent translation
|
|
66
|
+
|
|
67
|
+
The `SpineforgeSpanProcessor` (a custom OTel `SpanProcessor`) receives every completed span and classifies it:
|
|
68
|
+
|
|
69
|
+
| Source | Span attribute | ActionEvent type |
|
|
70
|
+
|--------|---------------|------------------|
|
|
71
|
+
| Groq instrumentor | `gen_ai.system` present | `llm_call` |
|
|
72
|
+
| LangChain instrumentor | `traceloop.span.kind = "llm"` | `llm_call` |
|
|
73
|
+
| LangChain instrumentor | `traceloop.span.kind = "tool"` | `tool_call` |
|
|
74
|
+
| `@track_tool` decorator | `spineforge.tool_call = True` | `tool_call` |
|
|
75
|
+
|
|
76
|
+
Unrecognised spans (HTTP, framework internals) are silently ignored.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Sink Abstraction
|
|
81
|
+
|
|
82
|
+
All events flow through a `Sink` interface:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
class Sink(ABC):
|
|
86
|
+
def emit(self, event: dict) -> None: ...
|
|
87
|
+
def flush(self) -> None: ...
|
|
88
|
+
def shutdown(self) -> None: ...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Built-in sinks:**
|
|
92
|
+
- `ConsoleSink` — compact, coloured one-liners to stdout
|
|
93
|
+
- `FileSink` — non-blocking JSONL append via background thread + queue
|
|
94
|
+
- `APISink` — batched, non-blocking POSTs to the Spineforge backend with automatic retry and local file fallback
|
|
95
|
+
|
|
96
|
+
**Adding a custom sink (e.g. `SupabaseSink`):**
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from spineforge.sinks import Sink
|
|
100
|
+
|
|
101
|
+
class SupabaseSink(Sink):
|
|
102
|
+
def __init__(self, supabase_url: str, api_key: str):
|
|
103
|
+
self._client = create_supabase_client(supabase_url, api_key)
|
|
104
|
+
|
|
105
|
+
def emit(self, event: dict) -> None:
|
|
106
|
+
table = "runs" if event["event"] == "run" else "actions"
|
|
107
|
+
self._client.table(table).insert(event).execute()
|
|
108
|
+
|
|
109
|
+
def flush(self) -> None:
|
|
110
|
+
pass # or batch-flush pending writes
|
|
111
|
+
|
|
112
|
+
def shutdown(self) -> None:
|
|
113
|
+
self._client.close()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
No changes to `instrumentation.py` or `span_processor.py` needed — just add the sink to the list in `__init__.py:init()`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Adding a New Framework Instrumentor
|
|
121
|
+
|
|
122
|
+
To add support for a new framework (e.g. CrewAI):
|
|
123
|
+
|
|
124
|
+
### 1. Install the instrumentor package
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install opentelemetry-instrumentation-crewai
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 2. Add activation in `instrumentation.py`
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# In _instrument_libraries():
|
|
134
|
+
try:
|
|
135
|
+
from opentelemetry.instrumentation.crewai import CrewAIInstrumentor
|
|
136
|
+
CrewAIInstrumentor().instrument(tracer_provider=provider)
|
|
137
|
+
_log_instrumentor_status("CrewAI", True)
|
|
138
|
+
except ImportError:
|
|
139
|
+
_log_instrumentor_status("CrewAI", False, "not installed")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 3. Add span classification in `span_processor.py`
|
|
143
|
+
|
|
144
|
+
Add a new condition in `_classify_and_build()` to detect CrewAI-specific span attributes and route them to `_build_llm_event()` or a new tool event builder.
|
|
145
|
+
|
|
146
|
+
### 4. Add the dependency to `pyproject.toml`
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
"opentelemetry-instrumentation-crewai>=0.35.0",
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Which Example Agents Need `@track_tool`?
|
|
155
|
+
|
|
156
|
+
| Agent | File | `@track_tool` needed? | Why |
|
|
157
|
+
|-------|------|-----------------------|-----|
|
|
158
|
+
| Scratch (raw Groq) | `scratch_agent_demo.py` | **Yes** | The Groq instrumentor only wraps `client.chat.completions.create()` — it has no knowledge of tool execution. `call_tool()` must be decorated. |
|
|
159
|
+
| LangChain | `langchain_agent_demo.py` | **No** | The LangChain instrumentor hooks into LangChain's callback system (`on_tool_start`/`on_tool_end`), which fires automatically when `Tool.run()` is called via `AgentExecutor`. |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Output Formats
|
|
164
|
+
|
|
165
|
+
### Console (human-readable)
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
🦴 Spineforge initialised: agent='research-agent-scratch' spine_id=a1b2c3d4…
|
|
169
|
+
🦴 [SPINE:a1b2c3d4] RUN started input="What is quantum computing?"
|
|
170
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] ⚡ llm_call llama-3.1-8b-instant 342ms ✓ (prompt:128 comp:256)
|
|
171
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] 🔧 tool_call search_tool 1204ms ✓
|
|
172
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] ⚡ llm_call llama-3.1-8b-instant 567ms ✓ (prompt:384 comp:512)
|
|
173
|
+
🦴 [SPINE:a1b2c3d4] [RUN:e5f6g7h8] RUN completed 4521ms ✓
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### JSONL (`.spineforge/logs/actions.jsonl`)
|
|
177
|
+
|
|
178
|
+
Each line is a JSON object — either a `run` event or an `action` event:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{"event": "run", "run_id": "...", "spine_id": "...", "agent_name": "...", "status": "running", ...}
|
|
182
|
+
{"event": "action", "action_id": "...", "run_id": "...", "type": "llm_call", "name": "llama-3.1-8b-instant", ...}
|
|
183
|
+
{"event": "action", "action_id": "...", "run_id": "...", "type": "tool_call", "name": "search_tool", ...}
|
|
184
|
+
{"event": "run", "run_id": "...", "status": "success", "ended_at": "...", ...}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Configuration
|
|
190
|
+
|
|
191
|
+
| Env var | Default | Description |
|
|
192
|
+
|---------|---------|-------------|
|
|
193
|
+
| `SPINEFORGE_DATA_DIR` | CWD | Base directory for `.spineforge/` |
|
|
194
|
+
| `SPINEFORGE_LOG_FILE` | `actions.jsonl` | JSONL log filename |
|
|
195
|
+
| `SPINEFORGE_VERBOSE` | `true` | Enable console output |
|
|
196
|
+
| `SPINEFORGE_TRACE_CONTENT` | `true` | Capture prompt/completion text |
|
|
197
|
+
| `SPINEFORGE_REGISTRY_URL` | `""` (empty) | URL of the Spineforge backend. Empty means offline mode. |
|
|
198
|
+
|
|
199
|
+
All can also be passed as kwargs to `spineforge.init()`.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Project Structure
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
spineforge-sdk/
|
|
207
|
+
├── pyproject.toml # Package metadata + dependencies
|
|
208
|
+
├── README.md # This file
|
|
209
|
+
├── spineforge/
|
|
210
|
+
│ ├── __init__.py # Public API: init(), track_tool, Spine, Run
|
|
211
|
+
│ ├── config.py # SpineforgeConfig (env-configurable)
|
|
212
|
+
│ ├── models.py # RunEvent, ActionEvent dataclasses
|
|
213
|
+
│ ├── identity.py # Spine ID registry (registry.json)
|
|
214
|
+
│ ├── sinks.py # Sink ABC + ConsoleSink + FileSink
|
|
215
|
+
│ ├── span_processor.py # SpineforgeSpanProcessor (OTel → events)
|
|
216
|
+
│ ├── instrumentation.py # TracerProvider + instrumentor wiring
|
|
217
|
+
│ └── decorators.py # @track_tool
|
|
218
|
+
├── examples/
|
|
219
|
+
│ ├── scratch_agent_demo.py # scratch.py + spineforge
|
|
220
|
+
│ ├── langchain_agent_demo.py # main.py + spineforge
|
|
221
|
+
│ └── tools.py # Shared tools
|
|
222
|
+
└── .spineforge/ # Created at runtime (gitignored)
|
|
223
|
+
├── registry.json
|
|
224
|
+
└── logs/
|
|
225
|
+
└── actions.jsonl
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
This software is proprietary and confidential. Unauthorized copying, distribution, reproduction, publication, or modification of this software, via any medium, is strictly prohibited. For internal use only.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Spineforge — Scope Model Reference
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Spineforge uses **scope-based access control** — direct scope grants per Spine ID.
|
|
6
|
+
There are no roles, no groups, no RBAC layer. Each agent gets a flat set of
|
|
7
|
+
scope strings that determine what it is allowed to do.
|
|
8
|
+
|
|
9
|
+
## Scope Format
|
|
10
|
+
|
|
11
|
+
Scopes are **free-form strings** matched by **exact equality** — no wildcards,
|
|
12
|
+
no pattern matching, no parsing. The recommended (not enforced) convention is:
|
|
13
|
+
|
|
14
|
+
| Pattern | Use case |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `lease:<resource>` | Credential leasing (e.g. `lease:groq-api-key`) |
|
|
17
|
+
| `call:<target-spine-id>:<action>` | Agent-to-agent calls (e.g. `call:abc123:echo`) |
|
|
18
|
+
|
|
19
|
+
You can use any string you like. The registry stores and compares them as-is.
|
|
20
|
+
|
|
21
|
+
## Lifecycle
|
|
22
|
+
|
|
23
|
+
### 1. Declaration (registration time)
|
|
24
|
+
|
|
25
|
+
Scopes are declared by the **developer** — not the agent — at registration time:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# SDK
|
|
29
|
+
spine = spineforge.init(agent_name="my-agent")
|
|
30
|
+
# Scopes are set via the registry's /agents/register endpoint
|
|
31
|
+
# with allowed_scopes=["lease:groq-api-key", "call:<target>:echo"]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or via the `/agents/register` HTTP endpoint:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"agent_name": "my-agent",
|
|
39
|
+
"public_key": "-----BEGIN PUBLIC KEY-----\n...",
|
|
40
|
+
"allowed_scopes": ["lease:groq-api-key", "call:target-agent:echo"]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Token request (runtime)
|
|
45
|
+
|
|
46
|
+
At runtime, the agent requests a token with a **subset** of its allowed scopes:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
token = spine.request_token(
|
|
50
|
+
scopes=["lease:groq-api-key"],
|
|
51
|
+
aud="registry", # or a target agent's spine_id
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The registry checks: `requested ⊆ allowed`. If not, the entire request is
|
|
56
|
+
rejected with `invalid_scope`. **No scope narrowing** — the token either
|
|
57
|
+
carries exactly what was requested, or it isn't issued at all.
|
|
58
|
+
|
|
59
|
+
### 3. Enforcement (at the resource)
|
|
60
|
+
|
|
61
|
+
The resource (registry lease endpoint, or a receiving agent) checks the
|
|
62
|
+
token's `scope` claim for the required scope string:
|
|
63
|
+
|
|
64
|
+
- **Lease endpoint**: checks `f"lease:{secret_name}"` is in scope
|
|
65
|
+
- **Receiving agent**: checks the required scope string using `verify_token()`
|
|
66
|
+
|
|
67
|
+
### 4. Scope updates
|
|
68
|
+
|
|
69
|
+
Changing an agent's allowed scopes in the DB affects **future token issuance
|
|
70
|
+
immediately**. Already-issued tokens remain valid until their own short TTL
|
|
71
|
+
expires (default: 15 minutes). This follows the same principle as credential
|
|
72
|
+
revocation — no instant-kill mechanism.
|
|
73
|
+
|
|
74
|
+
## Token Structure
|
|
75
|
+
|
|
76
|
+
Every access token carries these claims:
|
|
77
|
+
|
|
78
|
+
| Claim | Type | Description |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `sub` | string (UUID) | The agent's Spine ID |
|
|
81
|
+
| `name` | string | The agent's human-readable name |
|
|
82
|
+
| `scope` | string | Space-separated list of granted scopes |
|
|
83
|
+
| `aud` | string | `"registry"` for lease flows, or a target agent's spine_id |
|
|
84
|
+
| `iat` | int | Issued-at timestamp (epoch seconds) |
|
|
85
|
+
| `exp` | int | Expiry timestamp (epoch seconds) |
|
|
86
|
+
| `iss` | string | Always `"spineforge"` |
|
|
87
|
+
|
|
88
|
+
Tokens are signed with the registry's **Ed25519 private key** (EdDSA algorithm).
|
|
89
|
+
The corresponding public key is available at `/.well-known/jwks.json`.
|
|
90
|
+
|
|
91
|
+
## Offline Verification
|
|
92
|
+
|
|
93
|
+
Receiving agents verify tokens **locally** — no round-trip to the registry:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from spineforge.verify import verify_token
|
|
97
|
+
|
|
98
|
+
claims = verify_token(
|
|
99
|
+
token,
|
|
100
|
+
expected_aud="my-spine-id",
|
|
101
|
+
required_scope="call:my-spine-id:some_action",
|
|
102
|
+
registry_url="https://registry.spineforge.dev",
|
|
103
|
+
)
|
|
104
|
+
# The public key is fetched once from /.well-known/jwks.json and cached.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Verification checks (in order):
|
|
108
|
+
1. **Signature** — Ed25519 using the registry's public key
|
|
109
|
+
2. **Expiry** — `exp` must be in the future
|
|
110
|
+
3. **Issuer** — must be `"spineforge"`
|
|
111
|
+
4. **Audience** — must match `expected_aud`
|
|
112
|
+
5. **Scope** — `required_scope` must be present in the `scope` claim
|
|
113
|
+
|
|
114
|
+
## Design Principles
|
|
115
|
+
|
|
116
|
+
- **Scopes are developer-declared, not agent-defined.** An agent can never
|
|
117
|
+
create or expand its own scopes, even if compromised at runtime.
|
|
118
|
+
- **No scope narrowing.** If any requested scope isn't allowed, the entire
|
|
119
|
+
token request fails. The SDK never has to guess what it actually got.
|
|
120
|
+
- **External systems stay scope-blind.** Provider APIs (Groq, Stripe, etc.)
|
|
121
|
+
only ever see a working credential — no Spine ID, scope, or Spineforge
|
|
122
|
+
metadata leaks past the vault boundary.
|
|
123
|
+
- **Mutual auth is out of scope.** B verifies A. A does not verify B.
|
|
124
|
+
TLS on B's endpoint is sufficient for this pass.
|