spineforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spineforge/__init__.py +422 -0
- spineforge/config.py +122 -0
- spineforge/decorators.py +103 -0
- spineforge/identity.py +249 -0
- spineforge/instrumentation.py +218 -0
- spineforge/models.py +95 -0
- spineforge/registry_client.py +345 -0
- spineforge/sinks.py +572 -0
- spineforge/span_processor.py +402 -0
- spineforge/verify.py +222 -0
- spineforge-0.1.0.dist-info/METADATA +277 -0
- spineforge-0.1.0.dist-info/RECORD +14 -0
- spineforge-0.1.0.dist-info/WHEEL +4 -0
- spineforge-0.1.0.dist-info/licenses/LICENSE +7 -0
spineforge/__init__.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spineforge
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
AI agent identity and observability SDK.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
import spineforge
|
|
10
|
+
|
|
11
|
+
spine = spineforge.init(agent_name="my-agent")
|
|
12
|
+
|
|
13
|
+
with spine.run(input=user_query) as run:
|
|
14
|
+
result = do_agent_work(user_query)
|
|
15
|
+
run.set_output(result)
|
|
16
|
+
|
|
17
|
+
For tool functions not auto-captured by an instrumentor::
|
|
18
|
+
|
|
19
|
+
@spineforge.track_tool
|
|
20
|
+
def call_tool(name: str, query: str) -> str:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
Public API:
|
|
24
|
+
init(agent_name, **kwargs) → Spine
|
|
25
|
+
track_tool(func) → decorated func
|
|
26
|
+
Spine — agent identity + instrumentation handle
|
|
27
|
+
Run — context manager for a single agent invocation
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from typing import Any, Optional
|
|
37
|
+
from uuid import uuid4
|
|
38
|
+
|
|
39
|
+
from spineforge.config import SpineforgeConfig
|
|
40
|
+
from spineforge.decorators import track_tool
|
|
41
|
+
from spineforge.identity import SpineIdentity, resolve_or_create
|
|
42
|
+
from spineforge.instrumentation import setup_instrumentation
|
|
43
|
+
from spineforge.models import RunEvent
|
|
44
|
+
from spineforge.sinks import ConsoleSink, FileSink, Sink
|
|
45
|
+
from spineforge.span_processor import SpineforgeSpanProcessor, current_run_id
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Re-export public names
|
|
50
|
+
__all__ = ["init", "track_tool", "Spine", "Run", "__version__"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Run context manager ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Run:
|
|
57
|
+
"""Context manager representing a single top-level agent invocation.
|
|
58
|
+
|
|
59
|
+
Groups all LLM calls and tool calls that happen within the ``with``
|
|
60
|
+
block under one ``run_id``. This maps to a row in the future ``runs``
|
|
61
|
+
table.
|
|
62
|
+
|
|
63
|
+
Usage::
|
|
64
|
+
|
|
65
|
+
with spine.run(input="user question") as run:
|
|
66
|
+
result = agent.invoke(...)
|
|
67
|
+
run.set_output(result)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, spine: Spine, input: str = "") -> None:
|
|
71
|
+
self.run_id: str = str(uuid4())
|
|
72
|
+
self._spine = spine
|
|
73
|
+
self._input = input
|
|
74
|
+
self._output: Optional[str] = None
|
|
75
|
+
self._error: Optional[str] = None
|
|
76
|
+
self._started_at: Optional[str] = None
|
|
77
|
+
self._run_id_token: Any = None # ContextVar token for reset
|
|
78
|
+
|
|
79
|
+
def set_output(self, output: Any) -> None:
|
|
80
|
+
"""Record the final output of this run."""
|
|
81
|
+
self._output = str(output) if output is not None else None
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> Run:
|
|
84
|
+
"""Start the run: emit a 'running' event and set the run_id context."""
|
|
85
|
+
self._started_at = datetime.now(timezone.utc).isoformat()
|
|
86
|
+
|
|
87
|
+
# Set the ContextVar so SpineforgeSpanProcessor.on_start() can
|
|
88
|
+
# stamp this run_id onto every span created during the run.
|
|
89
|
+
self._run_id_token = current_run_id.set(self.run_id)
|
|
90
|
+
|
|
91
|
+
# Emit the "run started" event
|
|
92
|
+
event = RunEvent(
|
|
93
|
+
run_id=self.run_id,
|
|
94
|
+
spine_id=self._spine.spine_id,
|
|
95
|
+
agent_name=self._spine.agent_name,
|
|
96
|
+
started_at=self._started_at,
|
|
97
|
+
status="running",
|
|
98
|
+
input=self._input,
|
|
99
|
+
)
|
|
100
|
+
self._spine._emit_event(event.to_dict())
|
|
101
|
+
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
105
|
+
"""End the run: flush telemetry and emit the final 'run' event."""
|
|
106
|
+
ended_at = datetime.now(timezone.utc).isoformat()
|
|
107
|
+
|
|
108
|
+
# Determine status
|
|
109
|
+
if exc_type is not None:
|
|
110
|
+
status = "error"
|
|
111
|
+
self._error = str(exc_val) if exc_val else str(exc_type.__name__)
|
|
112
|
+
else:
|
|
113
|
+
status = "success"
|
|
114
|
+
|
|
115
|
+
# Force-flush the TracerProvider so all in-flight spans from this
|
|
116
|
+
# run are processed BEFORE we emit the run-end event. This ensures
|
|
117
|
+
# correct ordering in the JSONL log (all actions before run-end).
|
|
118
|
+
if self._spine._provider:
|
|
119
|
+
try:
|
|
120
|
+
self._spine._provider.force_flush(timeout_millis=5000)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# Emit the "run completed/errored" event
|
|
125
|
+
event = RunEvent(
|
|
126
|
+
run_id=self.run_id,
|
|
127
|
+
spine_id=self._spine.spine_id,
|
|
128
|
+
agent_name=self._spine.agent_name,
|
|
129
|
+
started_at=self._started_at or ended_at,
|
|
130
|
+
ended_at=ended_at,
|
|
131
|
+
status=status,
|
|
132
|
+
input=self._input,
|
|
133
|
+
output=self._output,
|
|
134
|
+
error=self._error,
|
|
135
|
+
)
|
|
136
|
+
self._spine._emit_event(event.to_dict())
|
|
137
|
+
|
|
138
|
+
# Flush sinks so the run-end event is written immediately
|
|
139
|
+
for sink in self._spine._sinks:
|
|
140
|
+
try:
|
|
141
|
+
sink.flush()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Reset the ContextVar
|
|
146
|
+
if self._run_id_token is not None:
|
|
147
|
+
current_run_id.reset(self._run_id_token)
|
|
148
|
+
self._run_id_token = None
|
|
149
|
+
|
|
150
|
+
return False # Never suppress exceptions
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Spine (agent handle) ───────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Spine:
|
|
157
|
+
"""Initialised agent identity + instrumentation handle.
|
|
158
|
+
|
|
159
|
+
Created by ``spineforge.init()``. Holds the resolved Spine ID, the
|
|
160
|
+
OTel TracerProvider, the list of active sinks, and (when a registry
|
|
161
|
+
backend is configured) the registry client for token/credential ops.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
identity: SpineIdentity,
|
|
167
|
+
provider: Any, # TracerProvider
|
|
168
|
+
sinks: list[Sink],
|
|
169
|
+
processor: SpineforgeSpanProcessor,
|
|
170
|
+
config: SpineforgeConfig,
|
|
171
|
+
registry_client: Optional[Any] = None, # RegistryClient
|
|
172
|
+
private_key_pem: Optional[str] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.agent_name = identity.agent_name
|
|
175
|
+
self.spine_id = identity.spine_id
|
|
176
|
+
self.created_at = identity.created_at
|
|
177
|
+
self._identity = identity
|
|
178
|
+
self._provider = provider
|
|
179
|
+
self._sinks = sinks
|
|
180
|
+
self._processor = processor
|
|
181
|
+
self._config = config
|
|
182
|
+
self._registry_client = registry_client
|
|
183
|
+
self._private_key_pem = private_key_pem
|
|
184
|
+
|
|
185
|
+
def run(self, input: str = "") -> Run:
|
|
186
|
+
"""Create a new Run context manager.
|
|
187
|
+
|
|
188
|
+
Usage::
|
|
189
|
+
|
|
190
|
+
with spine.run(input="user question") as run:
|
|
191
|
+
result = agent.invoke(...)
|
|
192
|
+
run.set_output(result)
|
|
193
|
+
"""
|
|
194
|
+
return Run(self, input=input)
|
|
195
|
+
|
|
196
|
+
def lease_credential(self, secret_name: str) -> str:
|
|
197
|
+
"""Lease a provider secret from the registry backend.
|
|
198
|
+
|
|
199
|
+
Handles scoped token acquisition and token refresh automatically.
|
|
200
|
+
Returns the raw secret value.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
secret_name: Name of the secret (e.g. "groq-api-key").
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The decrypted secret value (e.g. the actual Groq API key).
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
RuntimeError if no registry is configured.
|
|
210
|
+
RegistryError on network/auth failures.
|
|
211
|
+
"""
|
|
212
|
+
if not self._registry_client:
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
"Cannot lease credentials — no registry URL configured. "
|
|
215
|
+
"Set SPINEFORGE_REGISTRY_URL to enable."
|
|
216
|
+
)
|
|
217
|
+
if not self._private_key_pem:
|
|
218
|
+
raise RuntimeError("No private key available for credential leasing")
|
|
219
|
+
|
|
220
|
+
value, _expires_at = self._registry_client.lease_credential(
|
|
221
|
+
secret_name=secret_name,
|
|
222
|
+
spine_id=self.spine_id,
|
|
223
|
+
private_key_pem=self._private_key_pem,
|
|
224
|
+
)
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
def request_token(
|
|
228
|
+
self,
|
|
229
|
+
scopes: list[str],
|
|
230
|
+
aud: str | None = None,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Request a scoped access token from the registry.
|
|
233
|
+
|
|
234
|
+
Wraps the RFC 7523 assertion flow, adding ``scopes`` and ``aud``
|
|
235
|
+
to the token request.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
scopes: List of scope strings (must be a subset of the agent's
|
|
239
|
+
allowed scopes declared at registration time).
|
|
240
|
+
aud: Audience claim. Defaults to ``"registry"`` for credential
|
|
241
|
+
lease flows. Set to a target agent's spine_id for
|
|
242
|
+
agent-to-agent calls.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The issued JWT string.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
RuntimeError if no registry is configured.
|
|
249
|
+
RegistryError if scopes are not granted or other failure.
|
|
250
|
+
"""
|
|
251
|
+
if not self._registry_client:
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
"Cannot request token — no registry URL configured. "
|
|
254
|
+
"Set SPINEFORGE_REGISTRY_URL to enable."
|
|
255
|
+
)
|
|
256
|
+
if not self._private_key_pem:
|
|
257
|
+
raise RuntimeError("No private key available for token request")
|
|
258
|
+
|
|
259
|
+
return self._registry_client.request_token(
|
|
260
|
+
spine_id=self.spine_id,
|
|
261
|
+
private_key_pem=self._private_key_pem,
|
|
262
|
+
scopes=scopes,
|
|
263
|
+
aud=aud,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _emit_event(self, event_dict: dict) -> None:
|
|
267
|
+
"""Route an event dict through all sinks."""
|
|
268
|
+
for sink in self._sinks:
|
|
269
|
+
try:
|
|
270
|
+
sink.emit(event_dict)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
logger.warning("Sink error: %s", exc)
|
|
273
|
+
|
|
274
|
+
def __repr__(self) -> str:
|
|
275
|
+
return (
|
|
276
|
+
f"Spine(agent_name={self.agent_name!r}, "
|
|
277
|
+
f"spine_id={self.spine_id[:8]}…)"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── init() — the one-call entry point ──────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def init(
|
|
285
|
+
agent_name: str,
|
|
286
|
+
allowed_scopes: list[str] | None = None,
|
|
287
|
+
**kwargs,
|
|
288
|
+
) -> Spine:
|
|
289
|
+
"""Initialise Spineforge for an agent.
|
|
290
|
+
|
|
291
|
+
This is the single entry point for the SDK. It:
|
|
292
|
+
|
|
293
|
+
1. Resolves (or creates) a stable Spine ID for ``agent_name``
|
|
294
|
+
2. If a registry URL is configured:
|
|
295
|
+
a. Generates/loads an Ed25519 keypair (private key stays local)
|
|
296
|
+
b. Registers with the backend (sends public key, gets spine_id)
|
|
297
|
+
c. Creates a registry client for token/credential operations
|
|
298
|
+
3. Configures OpenTelemetry with our custom TracerProvider
|
|
299
|
+
4. Activates OpenLLMetry instrumentors (Groq, LangChain, etc.)
|
|
300
|
+
5. Returns a ``Spine`` handle for creating runs
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
agent_name: Human-readable agent identifier. Same name → same
|
|
304
|
+
Spine ID across runs (persisted in registry.json).
|
|
305
|
+
allowed_scopes: Scopes declared by the agent developer at registration time.
|
|
306
|
+
Passed to the backend so the agent can later request tokens
|
|
307
|
+
for these scopes.
|
|
308
|
+
**kwargs: Override any ``SpineforgeConfig`` field (data_dir,
|
|
309
|
+
log_file, verbose, trace_content, registry_url).
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
A ``Spine`` instance. Use ``spine.run(input=...)`` to group
|
|
313
|
+
actions into runs.
|
|
314
|
+
|
|
315
|
+
Example::
|
|
316
|
+
|
|
317
|
+
import spineforge
|
|
318
|
+
|
|
319
|
+
spine = spineforge.init(agent_name="research-agent")
|
|
320
|
+
|
|
321
|
+
with spine.run(input=query) as run:
|
|
322
|
+
result = run_agent(query)
|
|
323
|
+
run.set_output(result)
|
|
324
|
+
"""
|
|
325
|
+
# 1. Load config from env + overrides
|
|
326
|
+
config = SpineforgeConfig.from_env(**kwargs)
|
|
327
|
+
config.ensure_dirs()
|
|
328
|
+
|
|
329
|
+
# 2. Resolve/create identity (generates keypair if registry_url is set)
|
|
330
|
+
identity = resolve_or_create(agent_name, config)
|
|
331
|
+
|
|
332
|
+
# 3. Registry integration (if configured)
|
|
333
|
+
registry_client = None
|
|
334
|
+
private_key_pem = None
|
|
335
|
+
|
|
336
|
+
if config.registry_url:
|
|
337
|
+
from spineforge.registry_client import RegistryClient, RegistryError
|
|
338
|
+
from spineforge.identity import get_or_create_keypair
|
|
339
|
+
|
|
340
|
+
private_key_pem, public_key_pem = get_or_create_keypair(config)
|
|
341
|
+
registry_client = RegistryClient(config.registry_url)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# Register with the backend → get the server-assigned spine_id
|
|
345
|
+
server_spine_id = registry_client.register(
|
|
346
|
+
agent_name,
|
|
347
|
+
public_key_pem,
|
|
348
|
+
allowed_scopes=allowed_scopes,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Update identity to use the server-assigned spine_id
|
|
352
|
+
# (may differ from the locally-generated one)
|
|
353
|
+
identity = SpineIdentity(
|
|
354
|
+
agent_name=agent_name,
|
|
355
|
+
spine_id=server_spine_id,
|
|
356
|
+
created_at=identity.created_at,
|
|
357
|
+
public_key_pem=public_key_pem,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if config.verbose:
|
|
361
|
+
print(
|
|
362
|
+
f"🔐 Registered with Spineforge registry: "
|
|
363
|
+
f"spine_id={server_spine_id[:8]}…",
|
|
364
|
+
flush=True,
|
|
365
|
+
)
|
|
366
|
+
except RegistryError as exc:
|
|
367
|
+
# Graceful degradation: log warning, continue with local identity
|
|
368
|
+
logger.warning(
|
|
369
|
+
"Could not reach registry at %s — continuing in offline mode: %s",
|
|
370
|
+
config.registry_url,
|
|
371
|
+
exc,
|
|
372
|
+
)
|
|
373
|
+
if config.verbose:
|
|
374
|
+
print(
|
|
375
|
+
f"⚠️ Registry unreachable — running in offline mode",
|
|
376
|
+
flush=True,
|
|
377
|
+
)
|
|
378
|
+
registry_client = None
|
|
379
|
+
private_key_pem = None
|
|
380
|
+
|
|
381
|
+
# 4. Create sinks
|
|
382
|
+
sinks: list[Sink] = []
|
|
383
|
+
sinks.append(ConsoleSink(config))
|
|
384
|
+
file_sink = FileSink(config)
|
|
385
|
+
sinks.append(file_sink)
|
|
386
|
+
|
|
387
|
+
# If the registry is available, add APISink with FileSink as fallback
|
|
388
|
+
if registry_client:
|
|
389
|
+
from spineforge.sinks import APISink
|
|
390
|
+
|
|
391
|
+
api_sink = APISink(
|
|
392
|
+
config=config,
|
|
393
|
+
registry_client=registry_client,
|
|
394
|
+
spine_id=identity.spine_id,
|
|
395
|
+
private_key_pem=private_key_pem,
|
|
396
|
+
fallback_sink=file_sink,
|
|
397
|
+
)
|
|
398
|
+
sinks.append(api_sink)
|
|
399
|
+
|
|
400
|
+
# 5. Set up OTel pipeline (TracerProvider + instrumentors)
|
|
401
|
+
provider, processor = setup_instrumentation(identity, config, sinks)
|
|
402
|
+
|
|
403
|
+
# 6. Log startup
|
|
404
|
+
if config.verbose:
|
|
405
|
+
print(
|
|
406
|
+
f"🦴 Spineforge initialised: "
|
|
407
|
+
f"agent={agent_name!r} "
|
|
408
|
+
f"spine_id={identity.spine_id[:8]}… "
|
|
409
|
+
f"log={config.log_path}"
|
|
410
|
+
+ (f" registry={config.registry_url}" if config.registry_url else ""),
|
|
411
|
+
flush=True,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return Spine(
|
|
415
|
+
identity=identity,
|
|
416
|
+
provider=provider,
|
|
417
|
+
sinks=sinks,
|
|
418
|
+
processor=processor,
|
|
419
|
+
config=config,
|
|
420
|
+
registry_client=registry_client,
|
|
421
|
+
private_key_pem=private_key_pem,
|
|
422
|
+
)
|
spineforge/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spineforge.config
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Environment-configurable settings for the Spineforge SDK.
|
|
6
|
+
|
|
7
|
+
All settings can be overridden via environment variables or passed directly
|
|
8
|
+
to ``spineforge.init()``. Env vars take lowest precedence (kwargs win).
|
|
9
|
+
|
|
10
|
+
Environment Variables:
|
|
11
|
+
SPINEFORGE_DATA_DIR Base directory for .spineforge/ (default: CWD)
|
|
12
|
+
SPINEFORGE_LOG_FILE JSONL log filename (default: actions.jsonl)
|
|
13
|
+
SPINEFORGE_VERBOSE Enable console output (default: true)
|
|
14
|
+
SPINEFORGE_TRACE_CONTENT Capture prompt/completion text (default: true)
|
|
15
|
+
SPINEFORGE_REGISTRY_URL Registry backend URL (default: empty = offline mode)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_bool(key: str, default: bool = True) -> bool:
|
|
26
|
+
"""Read a boolean from an environment variable (true/1/yes → True)."""
|
|
27
|
+
val = os.environ.get(key, "").strip().lower()
|
|
28
|
+
if not val:
|
|
29
|
+
return default
|
|
30
|
+
return val in ("true", "1", "yes")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SpineforgeConfig:
|
|
35
|
+
"""Immutable configuration snapshot for a Spineforge session."""
|
|
36
|
+
|
|
37
|
+
# Base directory where .spineforge/ is created (registry + logs)
|
|
38
|
+
data_dir: Path = field(default_factory=lambda: Path.cwd())
|
|
39
|
+
|
|
40
|
+
# Filename for the JSONL action log (inside {data_dir}/.spineforge/logs/)
|
|
41
|
+
log_file: str = "actions.jsonl"
|
|
42
|
+
|
|
43
|
+
# Whether to print human-readable event lines to stdout
|
|
44
|
+
verbose: bool = True
|
|
45
|
+
|
|
46
|
+
# Whether to include prompt/completion content in logged events
|
|
47
|
+
trace_content: bool = True
|
|
48
|
+
|
|
49
|
+
# URL of the Spineforge registry backend.
|
|
50
|
+
# Empty string = offline mode (local registry.json only, no crypto identity).
|
|
51
|
+
registry_url: str = ""
|
|
52
|
+
|
|
53
|
+
# ── Derived paths (computed, not configurable directly) ──────────────
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def spineforge_dir(self) -> Path:
|
|
57
|
+
"""The .spineforge/ directory under data_dir."""
|
|
58
|
+
return self.data_dir / ".spineforge"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def registry_path(self) -> Path:
|
|
62
|
+
"""Path to registry.json (agent_name → spine_id mapping)."""
|
|
63
|
+
return self.spineforge_dir / "registry.json"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def key_path(self) -> Path:
|
|
67
|
+
"""Path to the agent's Ed25519 private key (PEM format)."""
|
|
68
|
+
return self.spineforge_dir / "agent_key.pem"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def logs_dir(self) -> Path:
|
|
72
|
+
"""Directory for JSONL log files."""
|
|
73
|
+
return self.spineforge_dir / "logs"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def log_path(self) -> Path:
|
|
77
|
+
"""Full path to the JSONL action log."""
|
|
78
|
+
return self.logs_dir / self.log_file
|
|
79
|
+
|
|
80
|
+
# ── Factory ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_env(cls, **overrides) -> SpineforgeConfig:
|
|
84
|
+
"""Build config from environment variables, with optional kwarg overrides.
|
|
85
|
+
|
|
86
|
+
Kwargs override env vars. Unset values fall back to class defaults.
|
|
87
|
+
"""
|
|
88
|
+
data_dir = overrides.get(
|
|
89
|
+
"data_dir",
|
|
90
|
+
os.environ.get("SPINEFORGE_DATA_DIR", None),
|
|
91
|
+
)
|
|
92
|
+
log_file = overrides.get(
|
|
93
|
+
"log_file",
|
|
94
|
+
os.environ.get("SPINEFORGE_LOG_FILE", None),
|
|
95
|
+
)
|
|
96
|
+
verbose = overrides.get("verbose", None)
|
|
97
|
+
trace_content = overrides.get("trace_content", None)
|
|
98
|
+
registry_url = overrides.get(
|
|
99
|
+
"registry_url",
|
|
100
|
+
os.environ.get("SPINEFORGE_REGISTRY_URL", ""),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
kwargs = {}
|
|
104
|
+
if data_dir is not None:
|
|
105
|
+
kwargs["data_dir"] = Path(data_dir)
|
|
106
|
+
if log_file is not None:
|
|
107
|
+
kwargs["log_file"] = log_file
|
|
108
|
+
if verbose is not None:
|
|
109
|
+
kwargs["verbose"] = verbose
|
|
110
|
+
else:
|
|
111
|
+
kwargs["verbose"] = _env_bool("SPINEFORGE_VERBOSE", default=True)
|
|
112
|
+
if trace_content is not None:
|
|
113
|
+
kwargs["trace_content"] = trace_content
|
|
114
|
+
else:
|
|
115
|
+
kwargs["trace_content"] = _env_bool("SPINEFORGE_TRACE_CONTENT", default=True)
|
|
116
|
+
kwargs["registry_url"] = registry_url.rstrip("/") if registry_url else ""
|
|
117
|
+
|
|
118
|
+
return cls(**kwargs)
|
|
119
|
+
|
|
120
|
+
def ensure_dirs(self) -> None:
|
|
121
|
+
"""Create the .spineforge/ directory tree if it doesn't exist."""
|
|
122
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
spineforge/decorators.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spineforge.decorators
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Manual instrumentation helpers for code paths not covered by auto-instrumentors.
|
|
6
|
+
|
|
7
|
+
Primary use case: the ``@track_tool`` decorator wraps a plain Python function
|
|
8
|
+
so it produces an OTel span that our ``SpineforgeSpanProcessor`` recognises as
|
|
9
|
+
a tool call.
|
|
10
|
+
|
|
11
|
+
When to use
|
|
12
|
+
~~~~~~~~~~~
|
|
13
|
+
- ``scratch.py``'s ``call_tool()`` — the Groq instrumentor only captures LLM
|
|
14
|
+
API calls, not tool execution. ``@track_tool`` fills the gap.
|
|
15
|
+
- Future agents using AutoGen, CrewAI, or raw Python tool functions that don't
|
|
16
|
+
go through LangChain's ``Tool.run()`` callback system.
|
|
17
|
+
|
|
18
|
+
When NOT to use
|
|
19
|
+
~~~~~~~~~~~~~~~
|
|
20
|
+
- ``main.py`` (LangChain agent) — the LangChain instrumentor's callback
|
|
21
|
+
handler already captures ``on_tool_start`` / ``on_tool_end`` automatically.
|
|
22
|
+
Adding ``@track_tool`` would create duplicate spans.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Callable, TypeVar
|
|
30
|
+
|
|
31
|
+
from opentelemetry import trace
|
|
32
|
+
from opentelemetry.trace import SpanKind, StatusCode
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
F = TypeVar("F", bound=Callable)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def track_tool(func: F) -> F:
|
|
40
|
+
"""Decorator that instruments a function as a Spineforge tool call.
|
|
41
|
+
|
|
42
|
+
Creates an OTel span with ``spineforge.tool_call=True`` so the
|
|
43
|
+
``SpineforgeSpanProcessor`` can identify it and emit an ActionEvent
|
|
44
|
+
of type ``"tool_call"``.
|
|
45
|
+
|
|
46
|
+
Usage::
|
|
47
|
+
|
|
48
|
+
@spineforge.track_tool
|
|
49
|
+
def call_tool(name: str, query: str) -> str:
|
|
50
|
+
tool = LC_TOOLS[name]
|
|
51
|
+
return str(tool.run(query))
|
|
52
|
+
|
|
53
|
+
The decorator captures:
|
|
54
|
+
- Function name as the tool name
|
|
55
|
+
- Arguments as input
|
|
56
|
+
- Return value as output
|
|
57
|
+
- Exceptions as errors (re-raised after recording)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@functools.wraps(func)
|
|
61
|
+
def wrapper(*args, **kwargs):
|
|
62
|
+
tracer = trace.get_tracer("spineforge.tools")
|
|
63
|
+
|
|
64
|
+
# Build a readable span name
|
|
65
|
+
span_name = f"tool_call:{func.__name__}"
|
|
66
|
+
|
|
67
|
+
# Build input description from args
|
|
68
|
+
# For call_tool(name, query), we want "name=search_tool, query=..."
|
|
69
|
+
input_parts = []
|
|
70
|
+
if args:
|
|
71
|
+
input_parts.append(", ".join(repr(a) for a in args))
|
|
72
|
+
if kwargs:
|
|
73
|
+
input_parts.append(
|
|
74
|
+
", ".join(f"{k}={v!r}" for k, v in kwargs.items())
|
|
75
|
+
)
|
|
76
|
+
input_str = ", ".join(input_parts)
|
|
77
|
+
|
|
78
|
+
with tracer.start_as_current_span(
|
|
79
|
+
span_name,
|
|
80
|
+
kind=SpanKind.INTERNAL,
|
|
81
|
+
attributes={
|
|
82
|
+
"spineforge.tool_call": True,
|
|
83
|
+
"spineforge.tool.name": func.__name__,
|
|
84
|
+
"spineforge.tool.input": input_str[:4096] if input_str else "",
|
|
85
|
+
},
|
|
86
|
+
) as span:
|
|
87
|
+
try:
|
|
88
|
+
result = func(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
# Record output (truncated to avoid huge spans)
|
|
91
|
+
result_str = str(result) if result is not None else ""
|
|
92
|
+
span.set_attribute(
|
|
93
|
+
"spineforge.tool.output", result_str[:4096]
|
|
94
|
+
)
|
|
95
|
+
span.set_status(StatusCode.OK)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
span.set_status(StatusCode.ERROR, str(exc))
|
|
100
|
+
span.set_attribute("spineforge.tool.error", str(exc)[:2048])
|
|
101
|
+
raise # Always re-raise — we observe, never swallow
|
|
102
|
+
|
|
103
|
+
return wrapper # type: ignore[return-value]
|