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 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)
@@ -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]