Agent Runs
Attribute LLM calls to a named unit of work with solwyn.run() — nested runs, spend tags, detached run handles for frameworks, and per-run caps
import os
import solwyn
from openai import OpenAI
client = solwyn.Solwyn(OpenAI(), api_key=os.environ["SOLWYN_API_KEY"])
with solwyn.run("nightly-batch", tags={"team": "research"}) as run_id:
client.chat.completions.create(model="gpt-4o", messages=[...])
client.chat.completions.create(model="gpt-4o", messages=[...])
client.close()Wrap a unit of work with solwyn.run(name) to attribute every LLM call inside it to a single agent run. The dashboard groups cost and latency by run, so you can answer questions like "what did this nightly batch cost?" or "which agent task is the most expensive?" — without standing up a separate project key per task. A run is also the unit an operator can stop from the dashboard.
Each call inside the scope is tagged with a fresh agent_run_id and the name you provided, carried on the metadata event. As with everything Solwyn sends, the tag is a structural label — never prompt or response content.
Basic usage
solwyn.run(name, tags=None, *, inherit_tags=True) returns a context manager that yields the generated run id. tags and inherit_tags are optional — see Tags:
import os
import solwyn
from openai import OpenAI
client = solwyn.Solwyn(OpenAI(), api_key=os.environ["SOLWYN_API_KEY"])
with solwyn.run("invoice-extraction") as run_id:
print(f"run id: {run_id}") # e.g. "run_3f8a..."
client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Extract the totals from this invoice."}],
)The run id is generated for you (run_ + a UUID). The dashboard aggregates by id, not name — so two scopes with the same name still produce two distinct runs.
Async
solwyn.run(...) works as both a sync (with) and async (async with) context manager. Because it is built on contextvars, the active run propagates correctly across await points and into asyncio tasks:
import os
import solwyn
from openai import AsyncOpenAI
client = solwyn.AsyncSolwyn(AsyncOpenAI(), api_key=os.environ["SOLWYN_API_KEY"])
async with solwyn.run("ingest-job") as run_id:
await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)Calls outside a run are still tracked
You do not have to wrap every call. Calls made outside any solwyn.run(...) scope are still reported — the Cloud API groups them into a synthetic per-day run:
_auto-{sdk_instance_id}-{YYYY-MM-DD}derived from the event's UTC timestamp. Use explicit runs when you want meaningful grouping; rely on the auto group for everything else. Auto-grouped runs carry no caller-supplied id, so they cannot be stopped from the dashboard.
Nesting
Nesting replaces the active run for the inner's duration — the same semantics as OpenTelemetry spans. The inner run() gets its own id, its metadata names the enclosing run as its parent, and the outer run is restored automatically on exit:
with solwyn.run("pipeline") as outer:
client.chat.completions.create(...) # run: pipeline
with solwyn.run("pipeline:summarize") as inner:
client.chat.completions.create(...) # run: pipeline:summarize, parent: pipeline
client.chat.completions.create(...) # run: pipeline againNew in 0.5.0. Every call inside a nested scope reports the immediately enclosing run as parent_agent_run_id. Root runs omit the field. Sibling scopes under one parent share it, and a grandchild points at its own parent, not the root — so the dashboard's Agents tab and the /agent-runs?parent=... listing can reconstruct the direct tree one level at a time. Nested scopes also inherit the parent's tags by default; see Nested scopes inherit tags.
Scopes must exit in LIFO order (guaranteed by with); exiting out of order raises a RuntimeError. The same rule applies to run handles.
Name validation
solwyn.run(name) validates the name when you call it, before the scope is entered. A name must be:
- a non-empty
str(after stripping whitespace) - free of control, format, and line-separator characters
- within the maximum length the SDK enforces
Invalid names raise ValueError (or TypeError for a non-string) immediately, before any call is tagged. start_run(...) and create_run(...) apply the same rules.
Tags
New in 0.3.0; client defaults and inheritance new in 0.5.0. Attach your own key/value labels to attributed calls — team, environment, job, tenant — for cost attribution, dashboard filtering, and tag-scoped budgets. There are three entry points, from lowest to highest precedence:
import os
import solwyn
from openai import OpenAI
client = solwyn.Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
tags={"environment": "production", "service_name": "research"}, # every call
)
with solwyn.run("nightly-batch", tags={"team": "research", "env": "prod"}) as run_id:
# environment=production, service_name=research, team=research, env=prod
client.chat.completions.create(model="gpt-4o", messages=[...])
# per-call tags merge over everything below them: env=staging wins
client.chat.completions.create(
model="gpt-4o",
messages=[...],
solwyn_tags={"env": "staging", "job": "backfill"},
)- Per client —
Solwyn(..., tags={...})orAsyncSolwyn(..., tags={...})supplies defaults to every intercepted call from that client, inside or outside a run. The keyword is keyword-only and falls back toSOLWYN_TAGS. - Per scope —
solwyn.run(name, tags={...})applies to every call inside the scope. - Per call —
solwyn_tags={...}, a reserved keyword argument the SDK strips from intercepted provider calls before the request reaches the provider.
Precedence is client defaults, then the active run scope, then per-call tags — a higher layer wins on a shared key and never removes a key it does not mention. Tags are copied when the client or scope is constructed and again when the call starts, so mutating the source dict afterwards does not change what is attributed.
Client default tags and SOLWYN_TAGS
SOLWYN_TAGS is a comma-separated list of key=value entries. Each entry splits at its first =, so a value may contain = but never a comma; whitespace is kept verbatim and an empty value (environment=) is legal. An entry without =, or any entry that fails the bounds below, raises ConfigurationError with field="tags" at construction — and so does a set-but-empty SOLWYN_TAGS=, so unset the variable rather than clearing it. Use the constructor mapping when a value must contain a comma.
Client-wide tags change how every call is admitted. A tagged call always takes a live budget check — it never rides the allow cache and never draws on a run lease — because a tag-scoped budget needs the tag snapshot to decide. Setting tags= on the client therefore puts every intercepted call on the per-call check path. See Tags and budget admission.
Nested scopes inherit tags
An inner scope keeps every parent tag it does not override and wins only on the keys it supplies:
with solwyn.run("nightly_eval", tags={"workflow": "nightly_eval", "team": "research"}):
with solwyn.run("summarizer", tags={"agent_name": "summarizer", "team": "safety"}):
context = solwyn.current_run_context()
assert context.tags == {
"workflow": "nightly_eval",
"team": "safety",
"agent_name": "summarizer",
}Pass inherit_tags=False to start a fresh tag scope. It affects tags only — the inner run still records the enclosing run as its parent:
with solwyn.run("nightly_eval", tags={"workflow": "nightly_eval"}):
with solwyn.run("one-off", tags={"agent_name": "one_off"}, inherit_tags=False):
assert solwyn.current_run_context().tags == {"agent_name": "one_off"}Bounds, validation, and clamping
Each mapping you supply is validated on its own, eagerly, before the budget check and before provider dispatch:
| Rule | Limit |
|---|---|
| Keys per supplied mapping | 10 |
| Key length | 1–64 characters — a key may not be empty |
| Value length | 0–256 characters — an empty value is legal |
| Characters | keys and values are strings and may not contain NUL |
A mapping that breaks a rule raises ValueError (or TypeError for a wrong type) with a prefix naming the mapping — solwyn_tags, solwyn.run(tags), or solwyn.create_run(tags) — so a malformed tag never reaches a provider or your bill. The client's own tags= mapping is validated at construction and raises ConfigurationError(field="tags") instead. The constants TAGS_MAX_KEYS, TAG_KEY_MAX_LENGTH, and TAG_VALUE_MAX_LENGTH are exported from solwyn.
The merged set is different. When the three layers together exceed 10 keys, the SDK does not raise inside the live call. It keeps 10 keys deterministically — per-call keys first, then scope keys, then client defaults, preserving insertion order within each layer — drops the lowest-priority excess from that call's attribution, emits one SolwynTagsClampedWarning (a UserWarning subclass, message merged tags exceed 10 keys; lower-priority tags were dropped), and dispatches the call:
six_scope_keys = {f"scope_{i}": "x" for i in range(6)}
five_call_keys = {f"call_{i}": "y" for i in range(5)}
with solwyn.run("batch", tags=six_scope_keys):
# 11 keys merged: all five call keys kept, five of the six scope keys kept,
# one scope key dropped, one SolwynTagsClampedWarning, call proceeds.
client.chat.completions.create(model="gpt-4o", messages=[...], solwyn_tags=five_call_keys)The warning cannot abort the call even under warnings.simplefilter("error"). Solwyn preserves keys verbatim and does not normalize case or whitespace — Team and team are two keys.
solwyn_tags must be a call argument on an intercepted surface. Passing it in default_params is a silent no-op — it is stripped from provider dispatch and never attached to attribution. Passing it to a surface Solwyn does not intercept (for example client.files.create(...)) forwards the unknown keyword to the provider SDK, which typically raises TypeError.
Tags and budget admission
New in 0.5.0. The captured tag snapshot rides the pre-flight budget check as well as the post-call metadata event. That is what lets a tag-scoped budget — "cap customer_tier=enterprise at $500 a month" — make an admission decision. Tags are never sent on confirmations or on lease requests.
Two consequences follow:
- Tagged calls always take a live check. They bypass the allow cache and the lease path, whether or not a run is active. A client-wide
tags=therefore puts every call on the per-call path; scope and per-call tags do so for the calls they cover. - A tag denial is selector-scoped. When the server denies under a tag rule,
BudgetExceededError.budget_periodis"tag"(the label is exposed on the exception since 0.6.0; earlier versions reported"unknown"). That denial is not remembered for the run or the project — it clears any project-wide sticky deny and never creates one — because it applies to one selector, not to the caller. Run-period (agent_run) and project-period denials keep their sticky behavior.
Tags and privacy
Tags ride the budget check and the metadata event; they never ride confirmations or lease requests. Unlike everything else Solwyn transmits, they are customer-supplied free text and sit outside the zero-content guarantee: never put prompt text, PII, or secrets in them. See Privacy.
Per-run budget caps
New in 0.3.0. Budgets can cap an individual run, not just the project. Every admission made inside a scope carries the run's stable agent_run_id, which is what a per-run cap is keyed on.
New in 0.4.0. A run is also the unit a budget lease is granted for. Token-billed calls inside a scope draw on one server-granted lease instead of checking with Solwyn before every call, which removes the per-call round-trip without weakening the cap. Caps, denials, and
BudgetExceededErrorbehave exactly as described below. See Run-scoped leases.
Caps are server-enforced and configured in Cloud — the dashboard or CLI, see Budgets. There is no cap argument on solwyn.run(...); the SDK's side of the contract is stamping the run id on the check and enforcing the answer. A denial arrives through the ordinary check path and is enforced client-side exactly like a project budget: BudgetExceededError (with budget_period == "agent_run" — the label is exposed on the exception since 0.6.0; earlier versions reported "unknown") in hard_deny mode, a warning in alert_only. See Budget Enforcement.
Two behaviors are specific to run-scoped calls:
- No allow cache. Run-scoped admission never rides a cached "allowed". Since 0.4.0 that no longer costs a round-trip per call: a token-billed run takes one lease and draws it down in memory, renewing in the background. Calls that fall outside lease eligibility — including every tagged call — still perform a real per-call check.
- Sticky denials. Once a run receives an authoritative hard deny, that denial is remembered for that run id (an LRU over the 128 most recent runs) — project-period hard denies stay global as before, and tag denials are never sticky. If Solwyn Cloud then becomes unreachable, the denial is preserved rather than failing open — and each time it is, the SDK logs a WARNING naming the usage and limit, on every call the preserved deny applies to; see Logging. Absent an authoritative deny, fail-open/fail-closed behavior is unchanged.
Stopping a run
New in 0.6.0. A run can be ended from outside the process. An operator presses Stop on the dashboard's Agents tab, or the SDK's local velocity detector decides the run is looping. Either way the next intercepted call inside that run raises RunStoppedError — deliberately not a BudgetExceededError, so a budget-denial handler cannot retry through it — and streams already open are aborted at their next chunk. A stop is per run, survives a Solwyn outage, and cannot be undone for that run id. The full contract is in Run control.
Reading the active run
solwyn.current_run_context() returns a RunContext(id, name, tags) named tuple for the active scope, or RunContext(None, None, None) when no scope is active. tags is a fresh copy of the scope's tags, including anything inherited from a parent scope; client defaults and per-call tags are applied later, when a call is captured, so they are not in this snapshot. Useful for correlating your own logs with the run the dashboard will show:
import solwyn
with solwyn.run("batch-42", tags={"team": "research"}):
context = solwyn.current_run_context()
logger.info("starting work", extra={"solwyn_run_id": context.id, "team": context.tags["team"]})solwyn.current_run() still returns the (agent_run_id, agent_run_name) pair, or (None, None).
Detached run handles
New in 0.6.0. A with solwyn.run(...) block assumes the work happens inside one context. Frameworks often do not cooperate: an agent framework fires a begin callback in one task and an end callback in another, a thread pool runs the provider call in a worker, or a stream is pulled turn by turn from whichever task the framework chose. Two functions return a RunHandle for those shapes.
| Function | What it does |
|---|---|
solwyn.create_run(name, tags=None, *, inherit_tags=True) | Creates a detached run identity without changing the current context. The handle snapshots its id, name, tags (inherited from the scope active at creation), and parent at creation time. Activate it anywhere with with handle.activate(): (also async with); each activation binds that same identity in the calling task or thread and yields the run id. Call handle.finish() once every activation has exited — from any context. |
solwyn.start_run(name, tags=None, *, inherit_tags=True) | Opens a scope immediately, like entering solwyn.run(...), and returns a handle whose finish() closes it. finish() must run in the same context that called start_run(); activate() is not available on these handles. Use it for begin/end callbacks that stay in one context. |
A RunHandle exposes exactly three members: run_id, activate(), and finish(). Read the active name and tags through current_run_context() inside an activation.
from concurrent.futures import ThreadPoolExecutor
import solwyn
def worker(handle: solwyn.RunHandle, prompt: str) -> str:
with handle.activate(): # binds the run in this worker thread
response = client.chat.completions.create(
model="gpt-4o", messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
with solwyn.run("fan-out", tags={"team": "research"}):
handle = solwyn.create_run("fan-out:worker") # parent: fan-out; inherits team=research
try:
with ThreadPoolExecutor() as executor:
results = list(executor.map(lambda p: worker(handle, p), prompts))
finally:
handle.finish()Every call made under an activation of the same handle shares one run id, so a multi-turn agent keeps one budget and lease ledger instead of minting a run per turn. Concurrent activations in different tasks or threads are fine; nesting an activation of the same handle inside itself in one context raises. The parent and inherited tags are fixed at create_run(...) time, not at activate() time — activating inside a different run does not re-parent the handle.
The rules the handle enforces, all as RuntimeError:
finish()while an activation is still live — deferred is the right response: retry after the activation exits.finish()twice, oractivate()afterfinish().- Exiting an activation (or a
start_runscope) from a different context than the one that entered it, or out of LIFO order. The handle stays usable afterwards.
There is no finalizer. A start_run handle that is never finished keeps its run active for the rest of that context — every later call in it is attributed to that run, silently. Always pair start_run/create_run with finish() in a finally, or let a shipped framework integration manage the handles for you.
The OpenAI Agents, LangChain, and CrewAI integrations are built on create_run(...): a detached identity per agent, chain node, or task, activated only around the provider call.
Concurrency edge cases
asyncio tasks capture the run at creation
A task created with asyncio.create_task(...) inside a run captures that run's context. If the task keeps making LLM calls after the with block exits, those calls are still attributed to the captured run id:
async with solwyn.run("fan-out"):
task = asyncio.create_task(do_more_calls()) # captures "fan-out"
# task may still be running here — its calls are still tagged "fan-out"When attribution must end with the block, use asyncio.TaskGroup or await spawned tasks before leaving the scope.
Threads need run_in_executor or a handle
contextvars propagate across asyncio tasks but not into ThreadPoolExecutor workers. Use solwyn.run_in_executor(...) so the ambient run follows threaded work:
from concurrent.futures import ThreadPoolExecutor
import solwyn
with solwyn.run("nightly-batch"), ThreadPoolExecutor() as executor:
future = solwyn.run_in_executor(executor, call_openai, prompt)
result = future.result()run_in_executor(...) returns the executor's concurrent.futures.Future, not an awaitable. In asyncio code, bridge it with asyncio.wrap_future(future). If you submit directly to an executor instead, wrap the callable with contextvars.copy_context().run(...) yourself — or hand the worker a detached handle and activate it inside the worker, which needs neither.
Do not open a run inside an async generator
A scope opened before a generator yield would remain active in the consumer's async for body — because Python runs the consumer's body in the same context after the yield — leaking the generator's run into unrelated consumer code. The SDK rejects this at scope entry with a TypeError:
# WRONG — raises TypeError at scope entry
async def stream_items():
async with solwyn.run("gen"): # not allowed inside an async generator
yield await fetch()
# Right — open the scope in the consumer instead
async with solwyn.run("consume"):
async for item in stream_items():
...What the dashboard sees
Each call inside a run reports these extra metadata fields:
| Field | Meaning |
|---|---|
agent_run_id | The active run's stable id, or None (then the API synthesizes the _auto-... group). |
agent_run_name | The human-readable label you passed to solwyn.run(name). |
parent_agent_run_id | The enclosing run's id for a nested run or a handle created inside one. Omitted for a root run. |
tags | The clamped snapshot produced from client defaults, the active run scope, and per-call tags. Omitted when empty. |
All four are structural labels; tags are the only customer-supplied free text. See Privacy for the complete list of transmitted fields.
API summary
| Symbol | Purpose |
|---|---|
solwyn.run(name, tags=None, *, inherit_tags=True) | Open an agent-run scope (sync with or async async with); yields the run id. tags applies to every call in the scope and merges over the parent's unless inherit_tags=False. |
solwyn.create_run(name, tags=None, *, inherit_tags=True) | Create a detached RunHandle without changing the current context; activate it anywhere. |
solwyn.start_run(name, tags=None, *, inherit_tags=True) | Open a scope now and return a RunHandle whose finish() closes it in the same context. |
RunHandle.run_id / .activate() / .finish() | The run's id; a sync-or-async context manager binding the run in the current task or thread (create_run handles only); release the handle. |
solwyn.run_in_executor(executor, fn, *args, **kwargs) | Submit fn to an executor with the active run preserved; returns a Future. |
solwyn.current_run_context() | Return RunContext(id, name, tags) for the active scope, or RunContext(None, None, None). |
solwyn.current_run() | Return the active (agent_run_id, agent_run_name), or (None, None). |
Solwyn(..., tags={...}) / SOLWYN_TAGS | Client default tags — the lowest-precedence layer, applied to every intercepted call. |
solwyn_tags={...} | Reserved keyword argument on an intercepted provider call; merges over the scope's and the client's tags and is stripped before dispatch. |
SolwynTagsClampedWarning, TAGS_MAX_KEYS, TAG_KEY_MAX_LENGTH, TAG_VALUE_MAX_LENGTH | The clamp warning class and the exported bounds. |
Related
- Run control — how an operator stop or a velocity stop reaches a run, and
RunStoppedError - Framework integrations — detached handles applied to OpenAI Agents, LangChain, and CrewAI
- Multi-Agent Cost Tracking — runs, tags, and the orchestrator pattern end to end
- Budget Enforcement — how a per-run or tag-scoped denial reaches your code
- Cloud: Agent runs — the Agents tab, the run listing API, and the stop endpoint
- Privacy — what the run fields and tags do and do not contain
- CLI: Agent integration — budget guardrails for spend the SDK does not wrap: check, spend, confirm
Provider Failover
Cross-provider and same-provider failover — the fallback chain, selection policies, request translation, and tuning
Run control
Stop a runaway agent run from the dashboard or from a local velocity rule — RunStoppedError, mid-stream aborts, stops that survive outages, and the denial receipts that record them.